Commit 80ca776dff
Verified · cmc
Layout: unified · split
internal/library/metacache.go added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | package library | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 14 | type 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. | |
| 21 | type MetaCache struct { | |
| 22 | path string | |
| 23 | entries map[string]metaEntry | |
| 24 | } | |
| 25 | ||
| 26 | func 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 | ||
| 42 | func (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 | ||
| 50 | func (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 | ||
| 54 | func (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 @@ | ||
| 1 | package library | |
| 2 | ||
| 3 | import ( | |
| 4 | "io/fs" | |
| 5 | "os" | |
| 6 | "path/filepath" | |
| 7 | "testing" | |
| 8 | "time" | |
| 9 | ||
| 10 | "gitbay.org/cmc/gallery/internal/format" | |
| 11 | ) | |
| 12 | ||
| 13 | func 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 | ||
| 26 | func 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 | ||
| 53 | func 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 | ||
| 75 | func 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 | } | |