Fix v1 follow-ups !3

merged merged by cmc on 2026-09-26 19:08 UTC · cmc/gallery:followups into main

17 files changed, +459 −50

Layout: unified · split

Dockerfile +2
@@ -8,6 +8,8 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /gallery ./cmd/gallery
8FROM alpine:3 8FROM alpine:3
9RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool 9RUN apk add --no-cache imagemagick imagemagick-jpeg imagemagick-heic imagemagick-webp exiftool
10COPY --from=build /gallery /usr/local/bin/gallery 10COPY --from=build /gallery /usr/local/bin/gallery
11RUN addgroup -g 1000 gallery && adduser -D -H -u 1000 -G gallery gallery
12USER gallery
11ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080 13ENV GALLERY_PHOTOS=/photos GALLERY_CACHE=/cache GALLERY_ADDR=:8080
12EXPOSE 8080 14EXPOSE 8080
13ENTRYPOINT ["gallery"] 15ENTRYPOINT ["gallery"]
README.org +2
@@ -61,3 +61,5 @@ GALLERY_PHOTOS_DIR=/path/to/photos docker compose up -d --build
61 61
62The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it. 62The container listens on =127.0.0.1:8003=; put a reverse proxy in front of it.
63Photos are mounted read-only; derivatives and =meta.json= live in =./cache=. 63Photos are mounted read-only; derivatives and =meta.json= live in =./cache=.
64The container runs as UID/GID 1000, so =./cache= must be writable by that user
65and the photos readable by it.
cmd/gallery/main.go +46 −7
@@ -11,6 +11,8 @@ import (
11 "os/signal" 11 "os/signal"
12 "path/filepath" 12 "path/filepath"
13 "runtime" 13 "runtime"
14 "strings"
15 "sync"
14 "syscall" 16 "syscall"
15 "time" 17 "time"
16 18
@@ -27,6 +29,27 @@ func env(key, def string) string {
27 return def 29 return def
28} 30}
29 31
32// checkDirs rejects a cache inside the photo root: every scan rewrites
33// meta.json, which the watcher would report as a change, forever.
34func checkDirs(photos, cache string) error {
35 p, err := filepath.Abs(photos)
36 if err != nil {
37 return err
38 }
39 c, err := filepath.Abs(cache)
40 if err != nil {
41 return err
42 }
43 rel, err := filepath.Rel(p, c)
44 if err != nil {
45 return err
46 }
47 if rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
48 return fmt.Errorf("cache %s must not be inside photos %s", cache, photos)
49 }
50 return nil
51}
52
30func main() { 53func main() {
31 photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)") 54 photos := flag.String("photos", env("GALLERY_PHOTOS", ""), "photo root (required)")
32 cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)") 55 cache := flag.String("cache", env("GALLERY_CACHE", ""), "cache directory (required)")
@@ -40,6 +63,9 @@ func main() {
40 flag.Usage() 63 flag.Usage()
41 os.Exit(2) 64 os.Exit(2)
42 } 65 }
66 if err := checkDirs(*photos, *cache); err != nil {
67 log.Fatal(err)
68 }
43 if err := format.CheckTools(); err != nil { 69 if err := format.CheckTools(); err != nil {
44 log.Fatal(err) 70 log.Fatal(err)
45 } 71 }
@@ -49,9 +75,12 @@ func main() {
49 if err != nil { 75 if err != nil {
50 log.Fatal(err) 76 log.Fatal(err)
51 } 77 }
52 rend := render.New(*cache, runtime.NumCPU()) 78 rend := render.New(*cache, runtime.GOMAXPROCS(0))
53 store := &library.Store{} 79 store := &library.Store{}
80 var scanMu sync.Mutex
54 rescan := func() error { 81 rescan := func() error {
82 scanMu.Lock()
83 defer scanMu.Unlock()
55 lib, err := library.Scan(*photos, reg, meta) 84 lib, err := library.Scan(*photos, reg, meta)
56 if err != nil { 85 if err != nil {
57 return err 86 return err
@@ -65,10 +94,6 @@ func main() {
65 log.Printf("library: %d albums", len(lib.Albums)) 94 log.Printf("library: %d albums", len(lib.Albums))
66 return nil 95 return nil
67 } 96 }
68 if err := rescan(); err != nil {
69 log.Fatal(err)
70 }
71
72 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 97 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
73 defer stop() 98 defer stop()
74 onChange := func() { 99 onChange := func() {
@@ -76,9 +101,22 @@ func main() {
76 log.Printf("rescan: %v", err) 101 log.Printf("rescan: %v", err)
77 } 102 }
78 } 103 }
104 // Serve an empty library while the first scan runs.
105 store.Set(&library.Library{})
106 // fatal stops exiftool first: it ignores EOF and would outlive the process.
107 fatal := func(err error) {
108 format.CloseExiftool()
109 log.Fatal(err)
110 }
111 // Watch before the first scan so files added during it are not missed.
79 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil { 112 if err := library.Watch(ctx, *photos, 500*time.Millisecond, onChange); err != nil {
80 log.Fatal(err) 113 log.Fatal(err)
81 } 114 }
115 go func() {
116 if err := rescan(); err != nil {
117 fatal(err)
118 }
119 }()
82 120
83 opt := web.Options{Title: *title, ThemePath: *theme} 121 opt := web.Options{Title: *title, ThemePath: *theme}
84 if *templates != "" { 122 if *templates != "" {
@@ -86,7 +124,7 @@ func main() {
86 } 124 }
87 h, err := web.New(store, rend, opt) 125 h, err := web.New(store, rend, opt)
88 if err != nil { 126 if err != nil {
89 log.Fatal(err) 127 fatal(err)
90 } 128 }
91 129
92 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second} 130 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
@@ -100,7 +138,8 @@ func main() {
100 }() 138 }()
101 log.Printf("listening on %s", *addr) 139 log.Printf("listening on %s", *addr)
102 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 140 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
103 log.Fatal(err) 141 fatal(err)
104 } 142 }
105 <-done 143 <-done
144 format.CloseExiftool()
106} 145}
cmd/gallery/main_test.go added +28
@@ -0,0 +1,28 @@
1package main
2
3import (
4 "path/filepath"
5 "testing"
6)
7
8func TestCheckDirs(t *testing.T) {
9 root := t.TempDir()
10 photos := filepath.Join(root, "photos")
11 for _, tc := range []struct {
12 cache string
13 ok bool
14 }{
15 {filepath.Join(root, "cache"), true},
16 {filepath.Join(root, "photos-cache"), true},
17 {photos, false},
18 {filepath.Join(photos, "cache"), false},
19 {filepath.Join(photos, ".cache"), false},
20 {filepath.Join(photos, "..cache"), false},
21 {filepath.Join(photos, "a", "..", "cache"), false},
22 } {
23 err := checkDirs(photos, tc.cache)
24 if (err == nil) != tc.ok {
25 t.Errorf("checkDirs(%q, %q) = %v, want ok=%v", photos, tc.cache, err, tc.ok)
26 }
27 }
28}
docs/specs/2026-09-25-gallery-design.org +2 −2
@@ -86,7 +86,7 @@ type Format interface {
86- Both share one ImageMagick-backed implementation parameterised by extensions: 86- Both share one ImageMagick-backed implementation parameterised by extensions:
87 - =Metadata=: =exiftool -json -n= with the fields above. Width and height are 87 - =Metadata=: =exiftool -json -n= with the fields above. Width and height are
88 swapped when the EXIF orientation is 5-8. 88 swapped when the EXIF orientation is 5-8.
89 - =Resize=: =magick <src> -auto-orient -resize <w>x -strip -quality 82 jpg:-= 89 - =Resize=: =magick -limit memory 512MiB -limit map 1GiB <src> -auto-orient -resize <w>x -strip -quality 82 jpg:-=
90 streamed to =dst=. Output is always JPEG in v1. 90 streamed to =dst=. Output is always JPEG in v1.
91- Adding a format (HEIC, AVIF, WebP, RAW) is a new registration. Adding video 91- Adding a format (HEIC, AVIF, WebP, RAW) is a new registration. Adding video
92 is a new =Kind= plus a template partial. Neither touches =library= or 92 is a new =Kind= plus a template partial. Neither touches =library= or
@@ -132,7 +132,7 @@ type Format interface {
132- Concurrent requests for the same key are collapsed with =singleflight=. 132- Concurrent requests for the same key are collapsed with =singleflight=.
133- Output is written to a temp file in the target directory and renamed into 133- Output is written to a temp file in the target directory and renamed into
134 place, so a failed or interrupted resize never leaves a partial file. 134 place, so a failed or interrupted resize never leaves a partial file.
135- A process-wide semaphore caps concurrent =magick= runs at =runtime.NumCPU()=. 135- A process-wide semaphore caps concurrent =magick= runs at =runtime.GOMAXPROCS(0)=.
136- Responses carry =Cache-Control: public, max-age=31536000, immutable=; the URL 136- Responses carry =Cache-Control: public, max-age=31536000, immutable=; the URL
137 changes when the source does because pages link with a =?v=<mtime>= query. 137 changes when the source does because pages link with a =?v=<mtime>= query.
138 138
internal/format/exiftool.go added +135
@@ -0,0 +1,135 @@
1package format
2
3import (
4 "bufio"
5 "bytes"
6 "errors"
7 "fmt"
8 "io"
9 "os/exec"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "time"
14)
15
16const exiftoolTimeout = 30 * time.Second
17
18// exiftool keeps one `exiftool -stay_open` process and sends it one request at a time.
19type exiftool struct {
20 mu sync.Mutex
21 cmd *exec.Cmd
22 stdin io.WriteCloser
23 stdout *bufio.Reader
24 closed bool
25 timeout time.Duration // per request; exiftoolTimeout when zero
26}
27
28var (
29 sharedExiftool exiftool
30 exiftoolStarts atomic.Int32
31)
32
33func (e *exiftool) start() error {
34 cmd := exec.Command("exiftool", "-stay_open", "True", "-@", "-")
35 stdin, err := cmd.StdinPipe()
36 if err != nil {
37 return err
38 }
39 stdout, err := cmd.StdoutPipe()
40 if err != nil {
41 return err
42 }
43 if err := cmd.Start(); err != nil {
44 return err
45 }
46 exiftoolStarts.Add(1)
47 e.cmd, e.stdin, e.stdout = cmd, stdin, bufio.NewReader(stdout)
48 return nil
49}
50
51// stop ends the process. exiftool ignores EOF on its argument stream, so a
52// graceful stop sends "-stay_open False"; otherwise the process is killed.
53func (e *exiftool) stop(graceful bool) {
54 if e.cmd == nil {
55 return
56 }
57 if graceful {
58 io.WriteString(e.stdin, "-stay_open\nFalse\n")
59 } else {
60 e.cmd.Process.Kill()
61 }
62 e.stdin.Close()
63 e.cmd.Wait()
64 e.cmd = nil
65}
66
67// close stops the process for good; later requests fail instead of restarting it.
68func (e *exiftool) close() {
69 e.mu.Lock()
70 defer e.mu.Unlock()
71 e.closed = true
72 e.stop(true)
73}
74
75// CloseExiftool stops the shared exiftool process. Call it before exiting.
76func CloseExiftool() {
77 sharedExiftool.close()
78}
79
80// run sends one request, one argument per line, and returns its stdout.
81// A broken pipe or a request exceeding the timeout kills the process; the
82// next request starts a new one.
83func (e *exiftool) run(args ...string) ([]byte, error) {
84 for _, a := range args {
85 if strings.ContainsAny(a, "\r\n") {
86 return nil, fmt.Errorf("argument contains a newline: %q", a)
87 }
88 }
89 e.mu.Lock()
90 defer e.mu.Unlock()
91 if e.closed {
92 return nil, errors.New("exiftool closed")
93 }
94 if e.cmd == nil {
95 if err := e.start(); err != nil {
96 return nil, err
97 }
98 }
99 timeout := e.timeout
100 if timeout == 0 {
101 timeout = exiftoolTimeout
102 }
103 proc := e.cmd.Process
104 var timedOut atomic.Bool
105 timer := time.AfterFunc(timeout, func() {
106 timedOut.Store(true)
107 proc.Kill()
108 })
109 defer timer.Stop()
110
111 fail := func(err error) ([]byte, error) {
112 e.stop(false)
113 if timedOut.Load() {
114 return nil, fmt.Errorf("timed out after %s", timeout)
115 }
116 return nil, err
117 }
118 if _, err := io.WriteString(e.stdin, strings.Join(args, "\n")+"\n-execute\n"); err != nil {
119 return fail(err)
120 }
121 var out []byte
122 for {
123 line, err := e.stdout.ReadBytes('\n')
124 if err != nil {
125 return fail(err)
126 }
127 if string(bytes.TrimRight(line, "\r\n")) == "{ready}" {
128 if !timer.Stop() {
129 e.stop(false) // the timer fired and killed the process as the read completed
130 }
131 return out, nil
132 }
133 out = append(out, line...)
134 }
135}
internal/format/exiftool_test.go added +58
@@ -0,0 +1,58 @@
1package format
2
3import (
4 "errors"
5 "os"
6 "syscall"
7 "testing"
8 "time"
9)
10
11func TestMain(m *testing.M) {
12 code := m.Run()
13 CloseExiftool()
14 os.Exit(code)
15}
16
17func TestExiftoolCloseEndsProcess(t *testing.T) {
18 requireTools(t)
19 var e exiftool
20 if _, err := e.run("-ver"); err != nil {
21 t.Fatal(err)
22 }
23 pid := e.cmd.Process.Pid
24 e.close()
25 if e.cmd != nil {
26 t.Fatal("cmd not cleared")
27 }
28 if err := syscall.Kill(pid, 0); !errors.Is(err, syscall.ESRCH) {
29 t.Fatalf("exiftool pid %d still running after close (kill 0: %v)", pid, err)
30 }
31}
32
33func TestExiftoolRunAfterCloseFails(t *testing.T) {
34 requireTools(t)
35 var e exiftool
36 e.close()
37 before := exiftoolStarts.Load()
38 if _, err := e.run("-ver"); err == nil {
39 t.Fatal("run after close: want error")
40 }
41 if exiftoolStarts.Load() != before {
42 t.Fatal("run after close started a process")
43 }
44}
45
46func TestExiftoolTimeoutKillsAndRecovers(t *testing.T) {
47 requireTools(t)
48 e := exiftool{timeout: time.Nanosecond}
49 if _, err := e.run("-ver"); err == nil {
50 t.Fatal("want timeout error")
51 }
52 e.timeout = 0
53 out, err := e.run("-ver")
54 if err != nil || len(out) == 0 {
55 t.Fatalf("run after timeout: %q, %v", out, err)
56 }
57 e.close()
58}
internal/format/magick.go +20 −5
@@ -5,6 +5,7 @@ import (
5 "fmt" 5 "fmt"
6 "io" 6 "io"
7 "os/exec" 7 "os/exec"
8 "path/filepath"
8 "strconv" 9 "strconv"
9 "strings" 10 "strings"
10) 11)
@@ -24,12 +25,18 @@ func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
24func (m *Magick) Kind() Kind { return KindImage } 25func (m *Magick) Kind() Kind { return KindImage }
25 26
26func (m *Magick) Metadata(path string) (Meta, error) { 27func (m *Magick) Metadata(path string) (Meta, error) {
27 out, err := exec.Command("exiftool", "-json", "-n", 28 // exiftool's argfile strips leading spaces and skips "#" lines, and a
29 // leading "-" would be read as an option; an absolute path avoids all three.
30 path, err := filepath.Abs(path)
31 if err != nil {
32 return Meta{}, err
33 }
34 out, err := sharedExiftool.run("-json", "-n",
28 "-ImageWidth", "-ImageHeight", "-Orientation", 35 "-ImageWidth", "-ImageHeight", "-Orientation",
29 "-DateTimeOriginal", "-CreateDate", 36 "-DateTimeOriginal", "-CreateDate",
30 "-Make", "-Model", "-LensModel", 37 "-Make", "-Model", "-LensModel",
31 "-FNumber", "-ExposureTime", "-ISO", 38 "-FNumber", "-ExposureTime", "-ISO",
32 path).Output() 39 path)
33 if err != nil { 40 if err != nil {
34 return Meta{}, fmt.Errorf("exiftool %s: %w", path, err) 41 return Meta{}, fmt.Errorf("exiftool %s: %w", path, err)
35 } 42 }
@@ -37,9 +44,7 @@ func (m *Magick) Metadata(path string) (Meta, error) {
37} 44}
38 45
39func (m *Magick) Resize(src string, width int, dst io.Writer) error { 46func (m *Magick) Resize(src string, width int, dst io.Writer) error {
40 cmd := exec.Command("magick", src, 47 cmd := exec.Command("magick", resizeArgs(src, width)...)
41 "-auto-orient", "-resize", strconv.Itoa(width)+"x",
42 "-strip", "-quality", "82", "jpg:-")
43 var stderr bytes.Buffer 48 var stderr bytes.Buffer
44 cmd.Stdout = dst 49 cmd.Stdout = dst
45 cmd.Stderr = &stderr 50 cmd.Stderr = &stderr
@@ -49,6 +54,16 @@ func (m *Magick) Resize(src string, width int, dst io.Writer) error {
49 return nil 54 return nil
50} 55}
51 56
57// resizeArgs caps ImageMagick's pixel cache so several concurrent resizes of
58// large sources cannot exhaust memory.
59func resizeArgs(src string, width int) []string {
60 return []string{
61 "-limit", "memory", "512MiB", "-limit", "map", "1GiB",
62 src, "-auto-orient", "-resize", strconv.Itoa(width) + "x",
63 "-strip", "-quality", "82", "jpg:-",
64 }
65}
66
52// Default returns the formats supported in v1. 67// Default returns the formats supported in v1.
53func Default() *Registry { 68func Default() *Registry {
54 r := &Registry{} 69 r := &Registry{}
internal/format/magick_test.go +55
@@ -6,6 +6,7 @@ import (
6 "image/jpeg" 6 "image/jpeg"
7 "os/exec" 7 "os/exec"
8 "path/filepath" 8 "path/filepath"
9 "strings"
9 "testing" 10 "testing"
10 "time" 11 "time"
11) 12)
@@ -118,3 +119,57 @@ func TestDefaultRegistry(t *testing.T) {
118 t.Error("heic should not match in v1") 119 t.Error("heic should not match in v1")
119 } 120 }
120} 121}
122
123func TestMagickMetadataManyFiles(t *testing.T) {
124 requireTools(t)
125 f := NewMagick("jpeg", ".jpg")
126 dir := t.TempDir()
127 before := exiftoolStarts.Load()
128 for i := 1; i <= 20; i++ {
129 p := filepath.Join(dir, fmt.Sprintf("%02d.jpg", i))
130 run(t, "magick", "-size", fmt.Sprintf("%dx10", i*10), "xc:gray", p)
131 if i == 10 {
132 if _, err := f.Metadata(filepath.Join(dir, "missing.jpg")); err == nil {
133 t.Fatal("missing file: want error")
134 }
135 }
136 m, err := f.Metadata(p)
137 if err != nil {
138 t.Fatalf("%s: %v", p, err)
139 }
140 if m.Width != i*10 || m.Height != 10 {
141 t.Fatalf("%s: got %dx%d, want %dx10", p, m.Width, m.Height, i*10)
142 }
143 }
144 if n := exiftoolStarts.Load() - before; n > 1 {
145 t.Fatalf("started exiftool %d times for 21 calls, want at most 1", n)
146 }
147}
148
149func TestMagickMetadataRejectsNewlineInPath(t *testing.T) {
150 if _, err := NewMagick("jpeg", ".jpg").Metadata("a\nb.jpg"); err == nil {
151 t.Fatal("want error")
152 }
153}
154
155func TestResizeArgsLimitResources(t *testing.T) {
156 got := strings.Join(resizeArgs("/p/a.jpg", 960), " ")
157 want := "-limit memory 512MiB -limit map 1GiB /p/a.jpg -auto-orient -resize 960x -strip -quality 82 jpg:-"
158 if got != want {
159 t.Fatalf("got %s\nwant %s", got, want)
160 }
161}
162
163func TestMagickMetadataRelativePathArgfileSafe(t *testing.T) {
164 requireTools(t)
165 dir := t.TempDir()
166 run(t, "magick", "-size", "30x20", "xc:gray", filepath.Join(dir, "#a.jpg"))
167 t.Chdir(dir)
168 m, err := NewMagick("jpeg", ".jpg").Metadata("#a.jpg")
169 if err != nil {
170 t.Fatal(err)
171 }
172 if m.Width != 30 || m.Height != 20 {
173 t.Fatalf("got %dx%d", m.Width, m.Height)
174 }
175}
internal/library/watch.go +33 −23
@@ -2,6 +2,7 @@ package library
2 2
3import ( 3import (
4 "context" 4 "context"
5 "errors"
5 "log" 6 "log"
6 "os" 7 "os"
7 "path/filepath" 8 "path/filepath"
@@ -23,35 +24,44 @@ func Watch(ctx context.Context, root string, debounce time.Duration, onChange fu
23 } 24 }
24 go func() { 25 go func() {
25 defer w.Close() 26 defer w.Close()
26 timer := time.NewTimer(debounce) 27 watchLoop(ctx, w.Events, w.Errors, w.Add, debounce, onChange)
27 timer.Stop() 28 }()
28 for { 29 return nil
29 select { 30}
30 case <-ctx.Done(): 31
32// watchLoop debounces events into onChange calls and watches newly created directories.
33// An event overflow means changes were dropped, so it schedules a rescan too.
34func watchLoop(ctx context.Context, events <-chan fsnotify.Event, errs <-chan error, add func(string) error, debounce time.Duration, onChange func()) {
35 timer := time.NewTimer(debounce)
36 timer.Stop()
37 for {
38 select {
39 case <-ctx.Done():
40 return
41 case ev, ok := <-events:
42 if !ok {
31 return 43 return
32 case ev, ok := <-w.Events: 44 }
33 if !ok { 45 if ev.Has(fsnotify.Create) {
34 return 46 if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
35 } 47 if err := add(ev.Name); err != nil {
36 if ev.Has(fsnotify.Create) { 48 log.Printf("watch %s: %v", ev.Name, err)
37 if fi, err := os.Stat(ev.Name); err == nil && fi.IsDir() {
38 if err := w.Add(ev.Name); err != nil {
39 log.Printf("watch %s: %v", ev.Name, err)
40 }
41 } 49 }
42 } 50 }
51 }
52 timer.Reset(debounce)
53 case err, ok := <-errs:
54 if !ok {
55 return
56 }
57 log.Printf("watch: %v", err)
58 if errors.Is(err, fsnotify.ErrEventOverflow) {
43 timer.Reset(debounce) 59 timer.Reset(debounce)
44 case err, ok := <-w.Errors:
45 if !ok {
46 return
47 }
48 log.Printf("watch: %v", err)
49 case <-timer.C:
50 onChange()
51 } 60 }
61 case <-timer.C:
62 onChange()
52 } 63 }
53 }() 64 }
54 return nil
55} 65}
56 66
57func addTree(w *fsnotify.Watcher, root string) error { 67func addTree(w *fsnotify.Watcher, root string) error {
internal/library/watch_test.go +14
@@ -6,6 +6,8 @@ import (
6 "path/filepath" 6 "path/filepath"
7 "testing" 7 "testing"
8 "time" 8 "time"
9
10 "github.com/fsnotify/fsnotify"
9) 11)
10 12
11func expectCall(t *testing.T, ch <-chan struct{}, what string) { 13func expectCall(t *testing.T, ch <-chan struct{}, what string) {
@@ -43,3 +45,15 @@ func TestWatchDebouncesAndFollowsNewDirs(t *testing.T) {
43 os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644) 45 os.WriteFile(filepath.Join(root, "iceland", "a.jpg"), []byte("x"), 0o644)
44 expectCall(t, calls, "write in new album") 46 expectCall(t, calls, "write in new album")
45} 47}
48
49func TestWatchLoopOverflowTriggersRescan(t *testing.T) {
50 ctx, cancel := context.WithCancel(context.Background())
51 defer cancel()
52 events := make(chan fsnotify.Event)
53 errs := make(chan error)
54 calls := make(chan struct{}, 1)
55 go watchLoop(ctx, events, errs, func(string) error { return nil }, 50*time.Millisecond, func() { calls <- struct{}{} })
56
57 errs <- fsnotify.ErrEventOverflow
58 expectCall(t, calls, "event overflow")
59}
internal/render/render.go +10
@@ -8,6 +8,7 @@ import (
8 "path/filepath" 8 "path/filepath"
9 "slices" 9 "slices"
10 "strconv" 10 "strconv"
11 "time"
11 12
12 "golang.org/x/sync/singleflight" 13 "golang.org/x/sync/singleflight"
13 14
@@ -84,6 +85,12 @@ func (r *Renderer) generate(it *library.Item, w int, dst string) error {
84 return os.Rename(tmp.Name(), dst) 85 return os.Rename(tmp.Name(), dst)
85} 86}
86 87
88// pruneGrace keeps stale directories modified this recently, since a request
89// holding an older library snapshot may still be writing into them. This is
90// best-effort: a directory's mtime changes when entries are created or
91// renamed, not while a file is written, so a narrow window remains.
92const pruneGrace = 5 * time.Minute
93
87// Prune removes derivative directories for items no longer in lib or whose source changed. 94// Prune removes derivative directories for items no longer in lib or whose source changed.
88func (r *Renderer) Prune(lib *library.Library) error { 95func (r *Renderer) Prune(lib *library.Library) error {
89 keep := map[string]bool{} 96 keep := map[string]bool{}
@@ -99,6 +106,9 @@ func (r *Renderer) Prune(lib *library.Library) error {
99 var firstErr error 106 var firstErr error
100 for _, v := range versions { 107 for _, v := range versions {
101 if !keep[v] { 108 if !keep[v] {
109 if fi, err := os.Stat(v); err == nil && time.Since(fi.ModTime()) < pruneGrace {
110 continue
111 }
102 if err := os.RemoveAll(v); err != nil { 112 if err := os.RemoveAll(v); err != nil {
103 log.Printf("prune %s: %v", v, err) 113 log.Printf("prune %s: %v", v, err)
104 if firstErr == nil { 114 if firstErr == nil {
internal/render/render_test.go +19
@@ -151,6 +151,10 @@ func TestPrune(t *testing.T) {
151 t.Fatal(err) 151 t.Fatal(err)
152 } 152 }
153 } 153 }
154 past := time.Now().Add(-2 * pruneGrace)
155 for _, it := range []*library.Item{cur, old, gone} {
156 os.Chtimes(filepath.Dir(r.Path(it, 480)), past, past)
157 }
154 lib := &library.Library{Albums: []*library.Album{{Slug: "tokyo", Items: []*library.Item{cur}}}} 158 lib := &library.Library{Albums: []*library.Album{{Slug: "tokyo", Items: []*library.Item{cur}}}}
155 if err := r.Prune(lib); err != nil { 159 if err := r.Prune(lib); err != nil {
156 t.Fatal(err) 160 t.Fatal(err)
@@ -164,3 +168,18 @@ func TestPrune(t *testing.T) {
164 } 168 }
165 } 169 }
166} 170}
171
172func TestPruneKeepsRecentlyModifiedStaleDir(t *testing.T) {
173 f := &fakeFormat{}
174 r := New(t.TempDir(), 1)
175 old := item(f)
176 if _, err := r.Get(old, 480); err != nil {
177 t.Fatal(err)
178 }
179 if err := r.Prune(&library.Library{}); err != nil {
180 t.Fatal(err)
181 }
182 if _, err := os.Stat(r.Path(old, 480)); err != nil {
183 t.Fatalf("recently written stale dir removed: %v", err)
184 }
185}
internal/web/funcs.go +15 −7
@@ -12,13 +12,14 @@ import (
12) 12)
13 13
14var funcs = template.FuncMap{ 14var funcs = template.FuncMap{
15 "albumURL": albumURL, 15 "albumURL": albumURL,
16 "photoURL": photoURL, 16 "photoURL": photoURL,
17 "imgURL": imgURL, 17 "imgURL": imgURL,
18 "srcset": srcset, 18 "srcset": srcset,
19 "ar": aspect, 19 "preloadSrcset": preloadSrcset,
20 "exif": exifLine, 20 "ar": aspect,
21 "add": func(a, b int) int { return a + b }, 21 "exif": exifLine,
22 "add": func(a, b int) int { return a + b },
22} 23}
23 24
24func albumURL(a *library.Album) string { 25func albumURL(a *library.Album) string {
@@ -50,6 +51,13 @@ func srcset(it *library.Item) string {
50 return strings.Join(parts, ", ") 51 return strings.Join(parts, ", ")
51} 52}
52 53
54// preloadSrcset renders the whole imagesrcset attribute: html/template treats
55// any unknown attribute containing "src" as a single URL and would escape the
56// spaces and commas a srcset list needs.
57func preloadSrcset(it *library.Item) template.HTMLAttr {
58 return template.HTMLAttr(`imagesrcset="` + template.HTMLEscapeString(srcset(it)) + `"`)
59}
60
53func aspect(it *library.Item) string { 61func aspect(it *library.Item) string {
54 if it.Meta.Height == 0 { 62 if it.Meta.Height == 0 {
55 return "1" 63 return "1"
internal/web/server_test.go +16 −2
@@ -95,8 +95,8 @@ func TestPages(t *testing.T) {
95 reject []string 95 reject []string
96 }{ 96 }{
97 {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil}, 97 {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil},
98 {"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`}, nil}, 98 {"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`, `alt="a.jpg"`}, []string{`alt=""`}},
99 {"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600 · 2025-03-14", `rel="next" href="/tokyo/b.jpg/"`, `id="up" href="/tokyo/"`}, []string{`rel="prev"`}}, 99 {"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600 · 2025-03-14", `rel="next" href="/tokyo/b.jpg/"`, `id="up" href="/tokyo/"`, `alt="a.jpg"`, `rel="preload" as="image" imagesrcset="/img/tokyo/b.jpg/480.jpg?v=`, ` 480w, /img/tokyo/b.jpg/960.jpg?v=`, `imagesizes="100vw"`}, []string{`rel="prev"`, `alt=""`}},
100 {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}}, 100 {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}},
101 {"/info/", 200, []string{"<em>there</em>"}, nil}, 101 {"/info/", 200, []string{"<em>there</em>"}, nil},
102 {"/nope/", 404, []string{"404"}, nil}, 102 {"/nope/", 404, []string{"404"}, nil},
@@ -209,3 +209,17 @@ func TestScript(t *testing.T) {
209 t.Fatal("album page missing script tag") 209 t.Fatal("album page missing script tag")
210 } 210 }
211} 211}
212
213func TestEmptyLibraryBeforeFirstScan(t *testing.T) {
214 store := &library.Store{}
215 store.Set(&library.Library{})
216 h, err := New(store, render.New(t.TempDir(), 1), Options{Title: "gallery"})
217 if err != nil {
218 t.Fatal(err)
219 }
220 for path, code := range map[string]int{"/": 200, "/tokyo/": 404, "/tokyo/a.jpg/": 404, "/img/tokyo/a.jpg/480.jpg": 404, "/info/": 404} {
221 if got := get(h, path).Code; got != code {
222 t.Errorf("%s: %d, want %d", path, got, code)
223 }
224 }
225}
internal/web/templates/album.html +1 −1
@@ -8,7 +8,7 @@
8<div class="rows"> 8<div class="rows">
9{{- range .Album.Items}} 9{{- range .Album.Items}}
10 <a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}"> 10 <a class="tile" href="{{photoURL .}}" data-ar="{{ar .}}" style="--ar:{{ar .}}">
11 <img src="{{imgURL . 960}}" srcset="{{srcset .}}" sizes="(max-width: 600px) 100vw, 33vw" width="{{.Meta.Width}}" height="{{.Meta.Height}}" loading="lazy" alt=""> 11 <img src="{{imgURL . 960}}" srcset="{{srcset .}}" sizes="(max-width: 600px) 100vw, 33vw" width="{{.Meta.Width}}" height="{{.Meta.Height}}" loading="lazy" alt="{{.Name}}">
12 </a> 12 </a>
13{{- end}} 13{{- end}}
14</div> 14</div>
internal/web/templates/photo.html +3 −3
@@ -3,7 +3,7 @@
3{{define "content"}} 3{{define "content"}}
4<figure class="stage"> 4<figure class="stage">
5 {{with .Prev}}<a class="prev" rel="prev" href="{{photoURL .}}" aria-label="Previous">‹</a>{{end}} 5 {{with .Prev}}<a class="prev" rel="prev" href="{{photoURL .}}" aria-label="Previous">‹</a>{{end}}
6 <img src="{{imgURL .Item 1600}}" srcset="{{srcset .Item}}" sizes="100vw" width="{{.Item.Meta.Width}}" height="{{.Item.Meta.Height}}" alt=""> 6 <img src="{{imgURL .Item 1600}}" srcset="{{srcset .Item}}" sizes="100vw" width="{{.Item.Meta.Width}}" height="{{.Item.Meta.Height}}" alt="{{.Item.Name}}">
7 {{with .Next}}<a class="next" rel="next" href="{{photoURL .}}" aria-label="Next">›</a>{{end}} 7 {{with .Next}}<a class="next" rel="next" href="{{photoURL .}}" aria-label="Next">›</a>{{end}}
8</figure> 8</figure>
9<footer class="meta"> 9<footer class="meta">
@@ -11,6 +11,6 @@
11 <span>{{exif .Item}}</span> 11 <span>{{exif .Item}}</span>
12</footer> 12</footer>
13<a id="up" href="{{albumURL .Album}}" hidden></a> 13<a id="up" href="{{albumURL .Album}}" hidden></a>
14{{with .Prev}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}} 14{{with .Prev}}<link rel="preload" as="image" {{preloadSrcset .}} imagesizes="100vw">{{end}}
15{{with .Next}}<link rel="preload" as="image" href="{{imgURL . 1600}}">{{end}} 15{{with .Next}}<link rel="preload" as="image" {{preloadSrcset .}} imagesizes="100vw">{{end}}
16{{end}} 16{{end}}