Commit cc1b9ac3e2
cc1b9ac3e2eb72aea01cd7e30fe1e32066e57f2a
parent: 57a8c2bca0
Verified · cmc
cmc <hello@cleberg.net> · 2026-09-26 00:22 UTC
Fix review findings: EXIF date, shutdown drain, incomplete scans
- library: Meta.Taken stays EXIF-only; add Item.Taken (EXIF or mtime
fallback) and use it for sorting/year logic instead of Meta.Taken
- web: exifLine appends the EXIF date when present
- main: wait for srv.Shutdown to finish before exiting
- library: Library.Incomplete marks an empty root or a failed album
read; Scan keeps the meta cache when incomplete; main skips pruning
when incomplete
- web: srcset stops at the first width >= source width instead of
emitting a duplicate descriptor
- render: Prune logs and continues past RemoveAll errors instead of
aborting
- web: imgURL cache-busts with ModTime.UnixNano instead of Unix
- gofmt internal/format/magick.go
Layout: unified · split
cmd/gallery/main.go
+6 −1
| @@ -57,7 +57,9 @@ func main() { |
| 57 | 57 | return err |
| 58 | 58 | } |
| 59 | 59 | store.Set(lib) |
| 60 | | if err := rend.Prune(lib); err != nil { |
| 60 | if lib.Incomplete { |
| 61 | log.Printf("scan incomplete: skipping prune") |
| 62 | } else if err := rend.Prune(lib); err != nil { |
| 61 | 63 | log.Printf("prune: %v", err) |
| 62 | 64 | } |
| 63 | 65 | log.Printf("library: %d albums", len(lib.Albums)) |
| @@ -88,14 +90,17 @@ func main() { |
| 88 | 90 | } |
| 89 | 91 | |
| 90 | 92 | srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second} |
| 93 | done := make(chan struct{}) |
| 91 | 94 | go func() { |
| 92 | 95 | <-ctx.Done() |
| 93 | 96 | sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 94 | 97 | defer cancel() |
| 95 | 98 | srv.Shutdown(sctx) |
| 99 | close(done) |
| 96 | 100 | }() |
| 97 | 101 | log.Printf("listening on %s", *addr) |
| 98 | 102 | if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { |
| 99 | 103 | log.Fatal(err) |
| 100 | 104 | } |
| 105 | <-done |
| 101 | 106 | } |
internal/format/magick.go
+1 −1
| @@ -19,7 +19,7 @@ func NewMagick(name string, exts ...string) *Magick { |
| 19 | 19 | return &Magick{name: name, exts: exts} |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | | func (m *Magick) Name() string { return m.name } |
| 22 | func (m *Magick) Name() string { return m.name } |
| 23 | 23 | func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) } |
| 24 | 24 | func (m *Magick) Kind() Kind { return KindImage } |
| 25 | 25 | |
internal/library/library.go
+11 −9
| @@ -12,9 +12,10 @@ import ( |
| 12 | 12 | ) |
| 13 | 13 | |
| 14 | 14 | type Library struct { |
| 15 | | Albums []*Album |
| 16 | | About template.HTML |
| 17 | | bySlug map[string]*Album |
| 15 | Albums []*Album |
| 16 | About template.HTML |
| 17 | Incomplete bool // set when the scan found zero albums or an album directory failed to read |
| 18 | bySlug map[string]*Album |
| 18 | 19 | } |
| 19 | 20 | |
| 20 | 21 | func (l *Library) Album(slug string) *Album { |
| @@ -46,8 +47,8 @@ func (a *Album) Year() string { |
| 46 | 47 | if len(a.Date) >= 4 { |
| 47 | 48 | return a.Date[:4] |
| 48 | 49 | } |
| 49 | | if a.Cover != nil && !a.Cover.Meta.Taken.IsZero() { |
| 50 | | return strconv.Itoa(a.Cover.Meta.Taken.Year()) |
| 50 | if a.Cover != nil && !a.Cover.Taken.IsZero() { |
| 51 | return strconv.Itoa(a.Cover.Taken.Year()) |
| 51 | 52 | } |
| 52 | 53 | return "" |
| 53 | 54 | } |
| @@ -60,8 +61,8 @@ func (a *Album) sortTime() time.Time { |
| 60 | 61 | } |
| 61 | 62 | var newest time.Time |
| 62 | 63 | for _, it := range a.Items { |
| 63 | | if it.Meta.Taken.After(newest) { |
| 64 | | newest = it.Meta.Taken |
| 64 | if it.Taken.After(newest) { |
| 65 | newest = it.Taken |
| 65 | 66 | } |
| 66 | 67 | } |
| 67 | 68 | return newest |
| @@ -73,6 +74,7 @@ type Item struct { |
| 73 | 74 | Path string |
| 74 | 75 | ModTime time.Time |
| 75 | 76 | Size int64 |
| 77 | Taken time.Time // EXIF date if set, else ModTime. |
| 76 | 78 | Meta format.Meta |
| 77 | 79 | Format format.Format |
| 78 | 80 | } |
| @@ -107,8 +109,8 @@ func sortAlbums(as []*Album) { |
| 107 | 109 | func sortItems(items []*Item, by string) { |
| 108 | 110 | sort.SliceStable(items, func(i, j int) bool { |
| 109 | 111 | a, b := items[i], items[j] |
| 110 | | if by == "taken" && !a.Meta.Taken.Equal(b.Meta.Taken) { |
| 111 | | return a.Meta.Taken.Before(b.Meta.Taken) |
| 112 | if by == "taken" && !a.Taken.Equal(b.Taken) { |
| 113 | return a.Taken.Before(b.Taken) |
| 112 | 114 | } |
| 113 | 115 | return a.Name < b.Name |
| 114 | 116 | }) |
internal/library/scan.go
+12 −4
| @@ -33,6 +33,7 @@ func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) |
| 33 | 33 | a, err := scanAlbum(root, name, reg, cache, keep) |
| 34 | 34 | if err != nil { |
| 35 | 35 | log.Printf("album %s: %v", name, err) |
| 36 | lib.Incomplete = true |
| 36 | 37 | continue |
| 37 | 38 | } |
| 38 | 39 | if len(a.Items) == 0 { |
| @@ -42,6 +43,9 @@ func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) |
| 42 | 43 | lib.bySlug[name] = a |
| 43 | 44 | } |
| 44 | 45 | sortAlbums(lib.Albums) |
| 46 | if len(lib.Albums) == 0 { |
| 47 | lib.Incomplete = true |
| 48 | } |
| 45 | 49 | |
| 46 | 50 | if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil { |
| 47 | 51 | if _, html, err := parseOrg(src, "about.org"); err != nil { |
| @@ -50,7 +54,9 @@ func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) |
| 50 | 54 | lib.About = html |
| 51 | 55 | } |
| 52 | 56 | } |
| 53 | | if err := cache.save(keep); err != nil { |
| 57 | if lib.Incomplete { |
| 58 | log.Printf("scan incomplete: keeping existing meta cache") |
| 59 | } else if err := cache.save(keep); err != nil { |
| 54 | 60 | log.Printf("meta cache: %v", err) |
| 55 | 61 | } |
| 56 | 62 | return lib, nil |
| @@ -116,13 +122,15 @@ func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep m |
| 116 | 122 | cache.put(rel, fi, m) |
| 117 | 123 | } |
| 118 | 124 | keep[rel] = true |
| 119 | | if m.Taken.IsZero() { |
| 120 | | m.Taken = fi.ModTime() |
| 125 | taken := m.Taken |
| 126 | if taken.IsZero() { |
| 127 | taken = fi.ModTime() |
| 121 | 128 | } |
| 122 | 129 | a.Items = append(a.Items, &Item{ |
| 123 | 130 | Album: slug, Name: name, Path: path, |
| 124 | 131 | ModTime: fi.ModTime(), Size: fi.Size(), |
| 125 | | Meta: m, Format: f, |
| 132 | Taken: taken, |
| 133 | Meta: m, Format: f, |
| 126 | 134 | }) |
| 127 | 135 | } |
| 128 | 136 | |
internal/library/scan_test.go
+38 −2
| @@ -173,8 +173,11 @@ func TestScanTakenFallsBackToModTime(t *testing.T) { |
| 173 | 173 | mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC) |
| 174 | 174 | os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt) |
| 175 | 175 | a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo") |
| 176 | | if !a.Items[0].Meta.Taken.Equal(mt) { |
| 177 | | t.Fatalf("Taken = %v, want %v", a.Items[0].Meta.Taken, mt) |
| 176 | if !a.Items[0].Taken.Equal(mt) { |
| 177 | t.Fatalf("Taken = %v, want %v", a.Items[0].Taken, mt) |
| 178 | } |
| 179 | if !a.Items[0].Meta.Taken.IsZero() { |
| 180 | t.Fatalf("Meta.Taken = %v, want zero", a.Items[0].Meta.Taken) |
| 178 | 181 | } |
| 179 | 182 | } |
| 180 | 183 | |
| @@ -215,6 +218,39 @@ func TestScanAbout(t *testing.T) { |
| 215 | 218 | } |
| 216 | 219 | } |
| 217 | 220 | |
| 221 | func TestScanIncomplete(t *testing.T) { |
| 222 | root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"}) |
| 223 | cachePath := filepath.Join(t.TempDir(), "meta.json") |
| 224 | cache, err := OpenMetaCache(cachePath) |
| 225 | if err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | f := &fakeFormat{} |
| 229 | |
| 230 | lib := scanWith(t, root, f, cache) |
| 231 | if lib.Incomplete { |
| 232 | t.Fatal("normal scan should not be Incomplete") |
| 233 | } |
| 234 | if f.calls != 1 { |
| 235 | t.Fatalf("calls = %d, want 1", f.calls) |
| 236 | } |
| 237 | |
| 238 | empty := t.TempDir() |
| 239 | lib2 := scanWith(t, empty, f, cache) |
| 240 | if !lib2.Incomplete { |
| 241 | t.Fatal("empty root scan should be Incomplete") |
| 242 | } |
| 243 | |
| 244 | // The cache entry from the first scan must have survived the incomplete scan. |
| 245 | lib3 := scanWith(t, root, f, cache) |
| 246 | if lib3.Incomplete { |
| 247 | t.Fatal("rescan of the normal root should not be Incomplete") |
| 248 | } |
| 249 | if f.calls != 1 { |
| 250 | t.Fatalf("calls after rescan = %d, want 1 (cache entry should have survived the incomplete scan)", f.calls) |
| 251 | } |
| 252 | } |
| 253 | |
| 218 | 254 | func TestStore(t *testing.T) { |
| 219 | 255 | var s Store |
| 220 | 256 | if s.Load() != nil { |
internal/render/render.go
+7 −2
| @@ -3,6 +3,7 @@ package render |
| 3 | 3 | |
| 4 | 4 | import ( |
| 5 | 5 | "fmt" |
| 6 | "log" |
| 6 | 7 | "os" |
| 7 | 8 | "path/filepath" |
| 8 | 9 | "slices" |
| @@ -95,12 +96,16 @@ func (r *Renderer) Prune(lib *library.Library) error { |
| 95 | 96 | if err != nil { |
| 96 | 97 | return err |
| 97 | 98 | } |
| 99 | var firstErr error |
| 98 | 100 | for _, v := range versions { |
| 99 | 101 | if !keep[v] { |
| 100 | 102 | if err := os.RemoveAll(v); err != nil { |
| 101 | | return err |
| 103 | log.Printf("prune %s: %v", v, err) |
| 104 | if firstErr == nil { |
| 105 | firstErr = err |
| 106 | } |
| 102 | 107 | } |
| 103 | 108 | } |
| 104 | 109 | } |
| 105 | | return nil |
| 110 | return firstErr |
| 106 | 111 | } |
internal/web/funcs.go
+7 −3
| @@ -30,7 +30,7 @@ func photoURL(it *library.Item) string { |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | 32 | func imgURL(it *library.Item, w int) string { |
| 33 | | return fmt.Sprintf("/img/%s/%s/%d.jpg?v=%d", url.PathEscape(it.Album), url.PathEscape(it.Name), w, it.ModTime.Unix()) |
| 33 | return fmt.Sprintf("/img/%s/%s/%d.jpg?v=%d", url.PathEscape(it.Album), url.PathEscape(it.Name), w, it.ModTime.UnixNano()) |
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | // srcset lists allowed widths up to the first one that reaches the source width. |
| @@ -38,11 +38,12 @@ func srcset(it *library.Item) string { |
| 38 | 38 | var parts []string |
| 39 | 39 | for _, w := range render.Widths { |
| 40 | 40 | actual := w |
| 41 | | if it.Meta.Width > 0 && w >= it.Meta.Width { |
| 41 | reached := it.Meta.Width > 0 && w >= it.Meta.Width |
| 42 | if reached { |
| 42 | 43 | actual = it.Meta.Width |
| 43 | 44 | } |
| 44 | 45 | parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual)) |
| 45 | | if actual != w { |
| 46 | if reached { |
| 46 | 47 | break |
| 47 | 48 | } |
| 48 | 49 | } |
| @@ -67,5 +68,8 @@ func exifLine(it *library.Item) string { |
| 67 | 68 | if m.ISO > 0 { |
| 68 | 69 | parts = append(parts, "ISO "+strconv.Itoa(m.ISO)) |
| 69 | 70 | } |
| 71 | if !m.Taken.IsZero() { |
| 72 | parts = append(parts, m.Taken.Format("2006-01-02")) |
| 73 | } |
| 70 | 74 | return strings.Join(parts, " · ") |
| 71 | 75 | } |
internal/web/funcs_test.go
added
+35
| @@ -0,0 +1,35 @@ |
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | "time" |
| 7 | |
| 8 | "gitbay.org/cmc/gallery/internal/format" |
| 9 | "gitbay.org/cmc/gallery/internal/library" |
| 10 | ) |
| 11 | |
| 12 | func TestExifLineDate(t *testing.T) { |
| 13 | it := &library.Item{Meta: format.Meta{Camera: "X-T5", Taken: time.Date(2025, 3, 14, 23, 10, 5, 0, time.UTC)}} |
| 14 | if got, want := exifLine(it), "X-T5 · 2025-03-14"; got != want { |
| 15 | t.Fatalf("exifLine = %q, want %q", got, want) |
| 16 | } |
| 17 | } |
| 18 | |
| 19 | func TestExifLineNoDate(t *testing.T) { |
| 20 | it := &library.Item{Meta: format.Meta{Camera: "X-T5"}} |
| 21 | if got, want := exifLine(it), "X-T5"; got != want { |
| 22 | t.Fatalf("exifLine = %q, want %q", got, want) |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | func TestSrcsetStopsAtExactSourceWidth(t *testing.T) { |
| 27 | it := &library.Item{Album: "tokyo", Name: "a.jpg", Meta: format.Meta{Width: 960}} |
| 28 | got := srcset(it) |
| 29 | if n := strings.Count(got, "960w"); n != 1 { |
| 30 | t.Fatalf("srcset = %q, want exactly one 960w entry, got %d", got, n) |
| 31 | } |
| 32 | if n := strings.Count(got, ", "); n != 1 { |
| 33 | t.Fatalf("srcset = %q, want exactly two entries", got) |
| 34 | } |
| 35 | } |
internal/web/server_test.go
+1 −1
| @@ -96,7 +96,7 @@ func TestPages(t *testing.T) { |
| 96 | 96 | }{ |
| 97 | 97 | {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil}, |
| 98 | 98 | {"/tokyo/", 200, []string{`href="/tokyo/a.jpg/"`, `href="/tokyo/b.jpg/"`, `/img/tokyo/a.jpg/960.jpg?v=`, `480w`, `class="on"`}, nil}, |
| 99 | | {"/tokyo/a.jpg/", 200, []string{"a.jpg — 1 / 2", "X-T5 · f/2 · 1/125 · ISO 1600", `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/"`}, []string{`rel="prev"`}}, |
| 100 | 100 | {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}}, |
| 101 | 101 | {"/info/", 200, []string{"<em>there</em>"}, nil}, |
| 102 | 102 | {"/nope/", 404, []string{"404"}, nil}, |