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() {
5757 return err
5858 }
5959 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 {
6163 log.Printf("prune: %v", err)
6264 }
6365 log.Printf("library: %d albums", len(lib.Albums))
@@ -88,14 +90,17 @@ func main() {
8890 }
8991
9092 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
93 done := make(chan struct{})
9194 go func() {
9295 <-ctx.Done()
9396 sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
9497 defer cancel()
9598 srv.Shutdown(sctx)
99 close(done)
96100 }()
97101 log.Printf("listening on %s", *addr)
98102 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
99103 log.Fatal(err)
100104 }
105 <-done
101106}
internal/format/magick.go +1 −1
@@ -19,7 +19,7 @@ func NewMagick(name string, exts ...string) *Magick {
1919 return &Magick{name: name, exts: exts}
2020}
2121
22func (m *Magick) Name() string { return m.name }
22func (m *Magick) Name() string { return m.name }
2323func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
2424func (m *Magick) Kind() Kind { return KindImage }
2525
internal/library/library.go +11 −9
@@ -12,9 +12,10 @@ import (
1212)
1313
1414type 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
1819}
1920
2021func (l *Library) Album(slug string) *Album {
@@ -46,8 +47,8 @@ func (a *Album) Year() string {
4647 if len(a.Date) >= 4 {
4748 return a.Date[:4]
4849 }
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())
5152 }
5253 return ""
5354}
@@ -60,8 +61,8 @@ func (a *Album) sortTime() time.Time {
6061 }
6162 var newest time.Time
6263 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
6566 }
6667 }
6768 return newest
@@ -73,6 +74,7 @@ type Item struct {
7374 Path string
7475 ModTime time.Time
7576 Size int64
77 Taken time.Time // EXIF date if set, else ModTime.
7678 Meta format.Meta
7779 Format format.Format
7880}
@@ -107,8 +109,8 @@ func sortAlbums(as []*Album) {
107109func sortItems(items []*Item, by string) {
108110 sort.SliceStable(items, func(i, j int) bool {
109111 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)
112114 }
113115 return a.Name < b.Name
114116 })
internal/library/scan.go +12 −4
@@ -33,6 +33,7 @@ func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error)
3333 a, err := scanAlbum(root, name, reg, cache, keep)
3434 if err != nil {
3535 log.Printf("album %s: %v", name, err)
36 lib.Incomplete = true
3637 continue
3738 }
3839 if len(a.Items) == 0 {
@@ -42,6 +43,9 @@ func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error)
4243 lib.bySlug[name] = a
4344 }
4445 sortAlbums(lib.Albums)
46 if len(lib.Albums) == 0 {
47 lib.Incomplete = true
48 }
4549
4650 if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil {
4751 if _, html, err := parseOrg(src, "about.org"); err != nil {
@@ -50,7 +54,9 @@ func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error)
5054 lib.About = html
5155 }
5256 }
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 {
5460 log.Printf("meta cache: %v", err)
5561 }
5662 return lib, nil
@@ -116,13 +122,15 @@ func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep m
116122 cache.put(rel, fi, m)
117123 }
118124 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()
121128 }
122129 a.Items = append(a.Items, &Item{
123130 Album: slug, Name: name, Path: path,
124131 ModTime: fi.ModTime(), Size: fi.Size(),
125 Meta: m, Format: f,
132 Taken: taken,
133 Meta: m, Format: f,
126134 })
127135 }
128136
internal/library/scan_test.go +38 −2
@@ -173,8 +173,11 @@ func TestScanTakenFallsBackToModTime(t *testing.T) {
173173 mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC)
174174 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt)
175175 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)
178181 }
179182}
180183
@@ -215,6 +218,39 @@ func TestScanAbout(t *testing.T) {
215218 }
216219}
217220
221func 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
218254func TestStore(t *testing.T) {
219255 var s Store
220256 if s.Load() != nil {
internal/render/render.go +7 −2
@@ -3,6 +3,7 @@ package render
33
44import (
55 "fmt"
6 "log"
67 "os"
78 "path/filepath"
89 "slices"
@@ -95,12 +96,16 @@ func (r *Renderer) Prune(lib *library.Library) error {
9596 if err != nil {
9697 return err
9798 }
99 var firstErr error
98100 for _, v := range versions {
99101 if !keep[v] {
100102 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 }
102107 }
103108 }
104109 }
105 return nil
110 return firstErr
106111}
internal/web/funcs.go +7 −3
@@ -30,7 +30,7 @@ func photoURL(it *library.Item) string {
3030}
3131
3232func 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())
3434}
3535
3636// srcset lists allowed widths up to the first one that reaches the source width.
@@ -38,11 +38,12 @@ func srcset(it *library.Item) string {
3838 var parts []string
3939 for _, w := range render.Widths {
4040 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 {
4243 actual = it.Meta.Width
4344 }
4445 parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual))
45 if actual != w {
46 if reached {
4647 break
4748 }
4849 }
@@ -67,5 +68,8 @@ func exifLine(it *library.Item) string {
6768 if m.ISO > 0 {
6869 parts = append(parts, "ISO "+strconv.Itoa(m.ISO))
6970 }
71 if !m.Taken.IsZero() {
72 parts = append(parts, m.Taken.Format("2006-01-02"))
73 }
7074 return strings.Join(parts, " · ")
7175}
internal/web/funcs_test.go added +35
@@ -0,0 +1,35 @@
1package web
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "gitbay.org/cmc/gallery/internal/format"
9 "gitbay.org/cmc/gallery/internal/library"
10)
11
12func 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
19func 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
26func 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) {
9696 }{
9797 {"/", 200, []string{`href="/tokyo/"`, `data-ar="1.5000"`, "Tokyo", "2025", `href="/info/"`}, nil},
9898 {"/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"`}},
100100 {"/tokyo/b.jpg/", 200, []string{`rel="prev" href="/tokyo/a.jpg/"`, "2 / 2"}, []string{`rel="next"`}},
101101 {"/info/", 200, []string{"<em>there</em>"}, nil},
102102 {"/nope/", 404, []string{"404"}, nil},