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 return err 57 return err
58 } 58 }
59 store.Set(lib) 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 log.Printf("prune: %v", err) 63 log.Printf("prune: %v", err)
62 } 64 }
63 log.Printf("library: %d albums", len(lib.Albums)) 65 log.Printf("library: %d albums", len(lib.Albums))
@@ -88,14 +90,17 @@ func main() {
88 } 90 }
89 91
90 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second} 92 srv := &http.Server{Addr: *addr, Handler: h, ReadHeaderTimeout: 10 * time.Second}
93 done := make(chan struct{})
91 go func() { 94 go func() {
92 <-ctx.Done() 95 <-ctx.Done()
93 sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 96 sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
94 defer cancel() 97 defer cancel()
95 srv.Shutdown(sctx) 98 srv.Shutdown(sctx)
99 close(done)
96 }() 100 }()
97 log.Printf("listening on %s", *addr) 101 log.Printf("listening on %s", *addr)
98 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 102 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
99 log.Fatal(err) 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 return &Magick{name: name, exts: exts} 19 return &Magick{name: name, exts: exts}
20} 20}
21 21
22func (m *Magick) Name() string { return m.name } 22func (m *Magick) Name() string { return m.name }
23func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) } 23func (m *Magick) Match(path string) bool { return hasExt(path, m.exts) }
24func (m *Magick) Kind() Kind { return KindImage } 24func (m *Magick) Kind() Kind { return KindImage }
25 25
internal/library/library.go +11 −9
@@ -12,9 +12,10 @@ import (
12) 12)
13 13
14type Library struct { 14type Library struct {
15 Albums []*Album 15 Albums []*Album
16 About template.HTML 16 About template.HTML
17 bySlug map[string]*Album 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
20func (l *Library) Album(slug string) *Album { 21func (l *Library) Album(slug string) *Album {
@@ -46,8 +47,8 @@ func (a *Album) Year() string {
46 if len(a.Date) >= 4 { 47 if len(a.Date) >= 4 {
47 return a.Date[:4] 48 return a.Date[:4]
48 } 49 }
49 if a.Cover != nil && !a.Cover.Meta.Taken.IsZero() { 50 if a.Cover != nil && !a.Cover.Taken.IsZero() {
50 return strconv.Itoa(a.Cover.Meta.Taken.Year()) 51 return strconv.Itoa(a.Cover.Taken.Year())
51 } 52 }
52 return "" 53 return ""
53} 54}
@@ -60,8 +61,8 @@ func (a *Album) sortTime() time.Time {
60 } 61 }
61 var newest time.Time 62 var newest time.Time
62 for _, it := range a.Items { 63 for _, it := range a.Items {
63 if it.Meta.Taken.After(newest) { 64 if it.Taken.After(newest) {
64 newest = it.Meta.Taken 65 newest = it.Taken
65 } 66 }
66 } 67 }
67 return newest 68 return newest
@@ -73,6 +74,7 @@ type Item struct {
73 Path string 74 Path string
74 ModTime time.Time 75 ModTime time.Time
75 Size int64 76 Size int64
77 Taken time.Time // EXIF date if set, else ModTime.
76 Meta format.Meta 78 Meta format.Meta
77 Format format.Format 79 Format format.Format
78} 80}
@@ -107,8 +109,8 @@ func sortAlbums(as []*Album) {
107func sortItems(items []*Item, by string) { 109func sortItems(items []*Item, by string) {
108 sort.SliceStable(items, func(i, j int) bool { 110 sort.SliceStable(items, func(i, j int) bool {
109 a, b := items[i], items[j] 111 a, b := items[i], items[j]
110 if by == "taken" && !a.Meta.Taken.Equal(b.Meta.Taken) { 112 if by == "taken" && !a.Taken.Equal(b.Taken) {
111 return a.Meta.Taken.Before(b.Meta.Taken) 113 return a.Taken.Before(b.Taken)
112 } 114 }
113 return a.Name < b.Name 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 a, err := scanAlbum(root, name, reg, cache, keep) 33 a, err := scanAlbum(root, name, reg, cache, keep)
34 if err != nil { 34 if err != nil {
35 log.Printf("album %s: %v", name, err) 35 log.Printf("album %s: %v", name, err)
36 lib.Incomplete = true
36 continue 37 continue
37 } 38 }
38 if len(a.Items) == 0 { 39 if len(a.Items) == 0 {
@@ -42,6 +43,9 @@ func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error)
42 lib.bySlug[name] = a 43 lib.bySlug[name] = a
43 } 44 }
44 sortAlbums(lib.Albums) 45 sortAlbums(lib.Albums)
46 if len(lib.Albums) == 0 {
47 lib.Incomplete = true
48 }
45 49
46 if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil { 50 if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil {
47 if _, html, err := parseOrg(src, "about.org"); err != nil { 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 lib.About = html 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 log.Printf("meta cache: %v", err) 60 log.Printf("meta cache: %v", err)
55 } 61 }
56 return lib, nil 62 return lib, nil
@@ -116,13 +122,15 @@ func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep m
116 cache.put(rel, fi, m) 122 cache.put(rel, fi, m)
117 } 123 }
118 keep[rel] = true 124 keep[rel] = true
119 if m.Taken.IsZero() { 125 taken := m.Taken
120 m.Taken = fi.ModTime() 126 if taken.IsZero() {
127 taken = fi.ModTime()
121 } 128 }
122 a.Items = append(a.Items, &Item{ 129 a.Items = append(a.Items, &Item{
123 Album: slug, Name: name, Path: path, 130 Album: slug, Name: name, Path: path,
124 ModTime: fi.ModTime(), Size: fi.Size(), 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 mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC) 173 mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC)
174 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt) 174 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt)
175 a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo") 175 a := scanWith(t, root, &fakeFormat{}, nil).Album("tokyo")
176 if !a.Items[0].Meta.Taken.Equal(mt) { 176 if !a.Items[0].Taken.Equal(mt) {
177 t.Fatalf("Taken = %v, want %v", a.Items[0].Meta.Taken, 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
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
218func TestStore(t *testing.T) { 254func TestStore(t *testing.T) {
219 var s Store 255 var s Store
220 if s.Load() != nil { 256 if s.Load() != nil {
internal/render/render.go +7 −2
@@ -3,6 +3,7 @@ package render
3 3
4import ( 4import (
5 "fmt" 5 "fmt"
6 "log"
6 "os" 7 "os"
7 "path/filepath" 8 "path/filepath"
8 "slices" 9 "slices"
@@ -95,12 +96,16 @@ func (r *Renderer) Prune(lib *library.Library) error {
95 if err != nil { 96 if err != nil {
96 return err 97 return err
97 } 98 }
99 var firstErr error
98 for _, v := range versions { 100 for _, v := range versions {
99 if !keep[v] { 101 if !keep[v] {
100 if err := os.RemoveAll(v); err != nil { 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
32func imgURL(it *library.Item, w int) string { 32func 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// srcset lists allowed widths up to the first one that reaches the source width. 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 var parts []string 38 var parts []string
39 for _, w := range render.Widths { 39 for _, w := range render.Widths {
40 actual := w 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 actual = it.Meta.Width 43 actual = it.Meta.Width
43 } 44 }
44 parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual)) 45 parts = append(parts, fmt.Sprintf("%s %dw", imgURL(it, w), actual))
45 if actual != w { 46 if reached {
46 break 47 break
47 } 48 }
48 } 49 }
@@ -67,5 +68,8 @@ func exifLine(it *library.Item) string {
67 if m.ISO > 0 { 68 if m.ISO > 0 {
68 parts = append(parts, "ISO "+strconv.Itoa(m.ISO)) 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 return strings.Join(parts, " · ") 74 return strings.Join(parts, " · ")
71} 75}
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) {
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"`}, 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 {"/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},