Commit 007be939d6

007be939d6d4505fc9c528ece7c587b5dd664050

parent: ca9bbda518

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-26 00:09 UTC

Add gallery command

Layout: unified · split

README.org +46
@@ -3,4 +3,50 @@
33Self-hosted photo gallery. Albums are folders; resized copies are generated on
44request with ImageMagick and cached on disk.
55
6* Requirements
7
8Go 1.27, ImageMagick 7 (=magick=), exiftool.
9
10* Photos
11
12#+begin_example
13photos/
14 about.org optional; rendered as the Info page
15 tokyo/
16 album.org optional
17 DSCF0412.jpg
18 ...
19#+end_example
20
21=album.org= keywords, all optional:
22
23| Keyword | Meaning | Default |
24|------------+-------------------------------------------+--------------------|
25| =#+title= | album title | folder name |
26| =#+date= | =YYYY=, =YYYY-MM= or =YYYY-MM-DD= | newest photo |
27| =#+cover= | file name of the cover photo | first photo |
28| =#+order= | integer; ordered albums come first | unset |
29| =#+sort= | =taken= or =name= | =taken= |
30
31Text below the keywords is shown as the album description.
32
33* Run
34
35#+begin_src sh
36go build -o gallery ./cmd/gallery
37./gallery -photos ./photos -cache ./cache -addr :8080
38#+end_src
39
40| Flag | Env | Default |
41|--------------+---------------------+-----------|
42| =-photos= | =GALLERY_PHOTOS= | required |
43| =-cache= | =GALLERY_CACHE= | required |
44| =-addr= | =GALLERY_ADDR= | =:8080= |
45| =-theme= | =GALLERY_THEME= | embedded |
46| =-templates= | =GALLERY_TEMPLATES= | embedded |
47| =-title= | =GALLERY_TITLE= | =gallery= |
48
49=-theme= replaces [[file:internal/web/assets/theme.css]]. =-templates= points
50at a directory with the same file names as [[file:internal/web/templates]].
51
652See [[file:docs/specs/2026-09-25-gallery-design.org][the design spec]].
cmd/gallery/main.go added +101
@@ -0,0 +1,101 @@
1package main
2
3import (
4 "context"
5 "errors"
6 "flag"
7 "fmt"
8 "log"
9 "net/http"
10 "os"
11 "os/signal"
12 "path/filepath"
13 "runtime"
14 "syscall"
15 "time"
16
17 "gitbay.org/cmc/gallery/internal/format"
18 "gitbay.org/cmc/gallery/internal/library"
19 "gitbay.org/cmc/gallery/internal/render"
20 "gitbay.org/cmc/gallery/internal/web"
21)
22
23func env(key, def string) string {
24 if v := os.Getenv(key); v != "" {
25 return v
26 }
27 return def
28}
29
30func main() {
31 photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)")
32 cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)")
33 addr := flag.String("addr", env("GALLERY_ADDR", ":8080"), "listen address")
34 theme := flag.String("theme", env("GALLERY_THEME", ""), "replacement theme.css")
35 templates := flag.String("templates", env("GALLERY_TEMPLATES", ""), "replacement template directory")
36 title := flag.String("title", env("GALLERY_TITLE", "gallery"), "site name")
37 flag.Parse()
38 if *photos == "" || *cache == "" {
39 fmt.Fprintln(os.Stderr, "gallery: -photos and -cache are required")
40 flag.Usage()
41 os.Exit(2)
42 }
43 if err := format.CheckTools(); err != nil {
44 log.Fatal(err)
45 }
46
47 reg := format.Default()
48 meta, err := library.OpenMetaCache(filepath.Join(*cache, "meta.json"))
49 if err != nil {
50 log.Fatal(err)
51 }
52 rend := render.New(*cache, runtime.NumCPU())
53 store := &library.Store{}
54 rescan := func() error {
55 lib, err := library.Scan(*photos, reg, meta)
56 if err != nil {
57 return err
58 }
59 store.Set(lib)
60 if err := rend.Prune(lib); err != nil {
61 log.Printf("prune: %v", err)
62 }
63 log.Printf("library: %d albums", len(lib.Albums))
64 return nil
65 }
66 if err := rescan(); err != nil {
67 log.Fatal(err)
68 }
69
70 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
71 defer stop()
72 onChange := func() {
73 if err := rescan(); err != nil {
74 log.Printf("rescan: %v", err)
75 }
76 }
77 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
78 log.Fatal(err)
79 }
80
81 opt := web.Options{Title: *title, ThemePath: *theme}
82 if *templates != "" {
83 opt.Templates = os.DirFS(*templates)
84 }
85 h, err := web.New(store, rend, opt)
86 if err != nil {
87 log.Fatal(err)
88 }
89
90 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
91 go func() {
92 <-ctx.Done()
93 sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
94 defer cancel()
95 srv.Shutdown(sctx)
96 }()
97 log.Printf("listening on %s", *addr)
98 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
99 log.Fatal(err)
100 }
101}