Commit 80ca776dff

80ca776dff6530dff5761362ce89bd478b861dff

parent: 8e0015d7de

Verified · cmc

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

Add metadata cache

Layout: unified · split

internal/library/metacache.go added +72
@@ -0,0 +1,72 @@
1package library
2
3import (
4 "encoding/json"
5 "errors"
6 "io/fs"
7 "log"
8 "os"
9 "path/filepath"
10
11 "gitbay.org/cmc/gallery/internal/format"
12)
13
14type metaEntry struct {
15 ModTime int64 `json:"mtime"`
16 Size int64 `json:"size"`
17 Meta format.Meta `json:"meta"`
18}
19
20// MetaCache persists extracted metadata keyed by album-relative path.
21type MetaCache struct {
22 path string
23 entries map[string]metaEntry
24}
25
26func OpenMetaCache(path string) (*MetaCache, error) {
27 c := &MetaCache{path: path, entries: map[string]metaEntry{}}
28 data, err := os.ReadFile(path)
29 if errors.Is(err, fs.ErrNotExist) {
30 return c, nil
31 }
32 if err != nil {
33 return nil, err
34 }
35 if err := json.Unmarshal(data, &c.entries); err != nil {
36 log.Printf("meta cache %s unreadable, starting empty: %v", path, err)
37 c.entries = map[string]metaEntry{}
38 }
39 return c, nil
40}
41
42func (c *MetaCache) get(rel string, fi fs.FileInfo) (format.Meta, bool) {
43 e, ok := c.entries[rel]
44 if !ok || e.ModTime != fi.ModTime().UnixNano() || e.Size != fi.Size() {
45 return format.Meta{}, false
46 }
47 return e.Meta, true
48}
49
50func (c *MetaCache) put(rel string, fi fs.FileInfo, m format.Meta) {
51 c.entries[rel] = metaEntry{ModTime: fi.ModTime().UnixNano(), Size: fi.Size(), Meta: m}
52}
53
54func (c *MetaCache) save(keep map[string]bool) error {
55 for k := range c.entries {
56 if !keep[k] {
57 delete(c.entries, k)
58 }
59 }
60 data, err := json.Marshal(c.entries)
61 if err != nil {
62 return err
63 }
64 if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
65 return err
66 }
67 tmp := c.path + ".tmp"
68 if err := os.WriteFile(tmp, data, 0o644); err != nil {
69 return err
70 }
71 return os.Rename(tmp, c.path)
72}
internal/library/metacache_test.go added +85
@@ -0,0 +1,85 @@
1package library
2
3import (
4 "io/fs"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "gitbay.org/cmc/gallery/internal/format"
11)
12
13func statFile(t *testing.T, body string) (string, fs.FileInfo) {
14 t.Helper()
15 p := filepath.Join(t.TempDir(), "a.jpg")
16 if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
17 t.Fatal(err)
18 }
19 fi, err := os.Stat(p)
20 if err != nil {
21 t.Fatal(err)
22 }
23 return p, fi
24}
25
26func TestMetaCacheRoundTrip(t *testing.T) {
27 path := filepath.Join(t.TempDir(), "sub", "meta.json")
28 c, err := OpenMetaCache(path)
29 if err != nil {
30 t.Fatal(err)
31 }
32 _, fi := statFile(t, "x")
33 m := format.Meta{Width: 3, Height: 2, Camera: "X-T5", Taken: time.Date(2025, 3, 1, 0, 0, 0, 0, time.UTC)}
34 c.put("tokyo/a.jpg", fi, m)
35 c.put("gone/b.jpg", fi, m)
36 if err := c.save(map[string]bool{"tokyo/a.jpg": true}); err != nil {
37 t.Fatal(err)
38 }
39
40 c2, err := OpenMetaCache(path)
41 if err != nil {
42 t.Fatal(err)
43 }
44 got, ok := c2.get("tokyo/a.jpg", fi)
45 if !ok || !got.Taken.Equal(m.Taken) || got.Camera != "X-T5" || got.Width != 3 {
46 t.Fatalf("get = %+v, %v", got, ok)
47 }
48 if _, ok := c2.get("gone/b.jpg", fi); ok {
49 t.Fatal("entry outside keep survived save")
50 }
51}
52
53func TestMetaCacheMissOnChange(t *testing.T) {
54 c, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
55 p, fi := statFile(t, "x")
56 c.put("a.jpg", fi, format.Meta{Width: 1, Height: 1})
57
58 if err := os.WriteFile(p, []byte("longer"), 0o644); err != nil {
59 t.Fatal(err)
60 }
61 fi2, _ := os.Stat(p)
62 if _, ok := c.get("a.jpg", fi2); ok {
63 t.Fatal("size change should miss")
64 }
65
66 later := fi.ModTime().Add(time.Hour)
67 os.WriteFile(p, []byte("x"), 0o644)
68 os.Chtimes(p, later, later)
69 fi3, _ := os.Stat(p)
70 if _, ok := c.get("a.jpg", fi3); ok {
71 t.Fatal("mtime change should miss")
72 }
73}
74
75func TestMetaCacheCorruptStartsEmpty(t *testing.T) {
76 path := filepath.Join(t.TempDir(), "meta.json")
77 os.WriteFile(path, []byte("{nope"), 0o644)
78 c, err := OpenMetaCache(path)
79 if err != nil {
80 t.Fatal(err)
81 }
82 if len(c.entries) != 0 {
83 t.Fatalf("entries = %v", c.entries)
84 }
85}