Commit 538e7e75c5

538e7e75c58b3c2ffc539ffee159ed0e8ecd9ba6

parent: 80ca776dff

Verified · cmc

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

Scan photo folders into albums

Layout: unified · split

internal/library/library.go added +115
@@ -0,0 +1,115 @@
1// Package library turns the photo folder tree into albums and items.
2package library
3
4import (
5 "html/template"
6 "sort"
7 "strconv"
8 "sync/atomic"
9 "time"
10
11 "gitbay.org/cmc/gallery/internal/format"
12)
13
14type Library struct {
15 Albums []*Album
16 About template.HTML
17 bySlug map[string]*Album
18}
19
20func (l *Library) Album(slug string) *Album {
21 return l.bySlug[slug]
22}
23
24type Album struct {
25 Slug string
26 Title string
27 Date string
28 Description template.HTML
29 Order int
30 HasOrder bool
31 Cover *Item
32 Items []*Item
33 byName map[string]int
34}
35
36func (a *Album) Item(name string) (int, *Item) {
37 i, ok := a.byName[name]
38 if !ok {
39 return -1, nil
40 }
41 return i, a.Items[i]
42}
43
44// Year is the album's #+date year, else the cover's taken year.
45func (a *Album) Year() string {
46 if len(a.Date) >= 4 {
47 return a.Date[:4]
48 }
49 if a.Cover != nil && !a.Cover.Meta.Taken.IsZero() {
50 return strconv.Itoa(a.Cover.Meta.Taken.Year())
51 }
52 return ""
53}
54
55func (a *Album) sortTime() time.Time {
56 for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
57 if t, err := time.Parse(layout, a.Date); err == nil {
58 return t
59 }
60 }
61 var newest time.Time
62 for _, it := range a.Items {
63 if it.Meta.Taken.After(newest) {
64 newest = it.Meta.Taken
65 }
66 }
67 return newest
68}
69
70type Item struct {
71 Album string
72 Name string
73 Path string
74 ModTime time.Time
75 Size int64
76 Meta format.Meta
77 Format format.Format
78}
79
80// Store holds the current Library snapshot.
81type Store struct {
82 p atomic.Pointer[Library]
83}
84
85func (s *Store) Load() *Library { return s.p.Load() }
86func (s *Store) Set(l *Library) { s.p.Store(l) }
87
88// sortAlbums puts albums with #+order first (ascending), then the rest newest first.
89func sortAlbums(as []*Album) {
90 sort.SliceStable(as, func(i, j int) bool {
91 a, b := as[i], as[j]
92 if a.HasOrder != b.HasOrder {
93 return a.HasOrder
94 }
95 if a.HasOrder && a.Order != b.Order {
96 return a.Order < b.Order
97 }
98 if !a.HasOrder {
99 if ta, tb := a.sortTime(), b.sortTime(); !ta.Equal(tb) {
100 return ta.After(tb)
101 }
102 }
103 return a.Slug < b.Slug
104 })
105}
106
107func sortItems(items []*Item, by string) {
108 sort.SliceStable(items, func(i, j int) bool {
109 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 }
113 return a.Name < b.Name
114 })
115}
internal/library/scan.go added +142
@@ -0,0 +1,142 @@
1package library
2
3import (
4 "log"
5 "os"
6 "path/filepath"
7 "strconv"
8 "strings"
9
10 "gitbay.org/cmc/gallery/internal/format"
11)
12
13// Slugs that would collide with fixed routes.
14var reserved = map[string]bool{"img": true, "info": true, "_": true}
15
16// Scan reads every album under root. Unreadable albums and files are logged and skipped.
17func Scan(root string, reg *format.Registry, cache *MetaCache) (*Library, error) {
18 entries, err := os.ReadDir(root)
19 if err != nil {
20 return nil, err
21 }
22 lib := &Library{bySlug: map[string]*Album{}}
23 keep := map[string]bool{}
24 for _, e := range entries {
25 name := e.Name()
26 if !e.IsDir() || strings.HasPrefix(name, ".") {
27 continue
28 }
29 if reserved[name] {
30 log.Printf("skipping album %q: reserved name", name)
31 continue
32 }
33 a, err := scanAlbum(root, name, reg, cache, keep)
34 if err != nil {
35 log.Printf("album %s: %v", name, err)
36 continue
37 }
38 if len(a.Items) == 0 {
39 continue
40 }
41 lib.Albums = append(lib.Albums, a)
42 lib.bySlug[name] = a
43 }
44 sortAlbums(lib.Albums)
45
46 if src, err := os.ReadFile(filepath.Join(root, "about.org")); err == nil {
47 if _, html, err := parseOrg(src, "about.org"); err != nil {
48 log.Printf("about.org: %v", err)
49 } else {
50 lib.About = html
51 }
52 }
53 if err := cache.save(keep); err != nil {
54 log.Printf("meta cache: %v", err)
55 }
56 return lib, nil
57}
58
59func scanAlbum(root, slug string, reg *format.Registry, cache *MetaCache, keep map[string]bool) (*Album, error) {
60 dir := filepath.Join(root, slug)
61 entries, err := os.ReadDir(dir)
62 if err != nil {
63 return nil, err
64 }
65 a := &Album{Slug: slug, Title: slug, byName: map[string]int{}}
66 sortBy, cover := "taken", ""
67
68 if src, err := os.ReadFile(filepath.Join(dir, "album.org")); err == nil {
69 kw, desc, err := parseOrg(src, filepath.Join(slug, "album.org"))
70 if err != nil {
71 log.Printf("%s/album.org: %v", slug, err)
72 } else {
73 if v := kw["title"]; v != "" {
74 a.Title = v
75 }
76 a.Date, a.Description, cover = kw["date"], desc, kw["cover"]
77 if v := kw["order"]; v != "" {
78 if n, err := strconv.Atoi(v); err == nil {
79 a.Order, a.HasOrder = n, true
80 } else {
81 log.Printf("%s/album.org: bad order %q", slug, v)
82 }
83 }
84 switch v := kw["sort"]; v {
85 case "", "taken":
86 case "name":
87 sortBy = v
88 default:
89 log.Printf("%s/album.org: bad sort %q", slug, v)
90 }
91 }
92 }
93
94 for _, e := range entries {
95 name := e.Name()
96 if e.IsDir() || strings.HasPrefix(name, ".") {
97 continue
98 }
99 path := filepath.Join(dir, name)
100 f := reg.Lookup(path)
101 if f == nil {
102 continue
103 }
104 fi, err := e.Info()
105 if err != nil {
106 log.Printf("%s/%s: %v", slug, name, err)
107 continue
108 }
109 rel := slug + "/" + name
110 m, ok := cache.get(rel, fi)
111 if !ok {
112 if m, err = f.Metadata(path); err != nil {
113 log.Printf("%s: %v", rel, err)
114 continue
115 }
116 cache.put(rel, fi, m)
117 }
118 keep[rel] = true
119 if m.Taken.IsZero() {
120 m.Taken = fi.ModTime()
121 }
122 a.Items = append(a.Items, &Item{
123 Album: slug, Name: name, Path: path,
124 ModTime: fi.ModTime(), Size: fi.Size(),
125 Meta: m, Format: f,
126 })
127 }
128
129 sortItems(a.Items, sortBy)
130 for i, it := range a.Items {
131 a.byName[it.Name] = i
132 }
133 if i, ok := a.byName[cover]; ok {
134 a.Cover = a.Items[i]
135 } else if len(a.Items) > 0 {
136 if cover != "" {
137 log.Printf("%s/album.org: cover %q not found", slug, cover)
138 }
139 a.Cover = a.Items[0]
140 }
141 return a, nil
142}
internal/library/scan_test.go added +228
@@ -0,0 +1,228 @@
1package library
2
3import (
4 "errors"
5 "io"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "gitbay.org/cmc/gallery/internal/format"
13)
14
15type fakeFormat struct {
16 meta map[string]format.Meta // by base name
17 fail map[string]bool
18 calls int
19}
20
21func (f *fakeFormat) Name() string { return "fake" }
22func (f *fakeFormat) Match(p string) bool { return strings.EqualFold(filepath.Ext(p), ".jpg") }
23func (f *fakeFormat) Kind() format.Kind { return format.KindImage }
24func (f *fakeFormat) Resize(string, int, io.Writer) error { return nil }
25func (f *fakeFormat) Metadata(p string) (format.Meta, error) {
26 f.calls++
27 name := filepath.Base(p)
28 if f.fail[name] {
29 return format.Meta{}, errors.New("bad file")
30 }
31 if m, ok := f.meta[name]; ok {
32 return m, nil
33 }
34 return format.Meta{Width: 300, Height: 200}, nil
35}
36
37func day(n int) time.Time { return time.Date(2025, 3, n, 0, 0, 0, 0, time.UTC) }
38
39func writeTree(t *testing.T, files map[string]string) string {
40 t.Helper()
41 root := t.TempDir()
42 for rel, body := range files {
43 p := filepath.Join(root, rel)
44 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
45 t.Fatal(err)
46 }
47 if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
48 t.Fatal(err)
49 }
50 }
51 return root
52}
53
54func scanWith(t *testing.T, root string, f *fakeFormat, cache *MetaCache) *Library {
55 t.Helper()
56 if cache == nil {
57 var err error
58 if cache, err = OpenMetaCache(filepath.Join(t.TempDir(), "meta.json")); err != nil {
59 t.Fatal(err)
60 }
61 }
62 reg := &format.Registry{}
63 reg.Register(f)
64 lib, err := Scan(root, reg, cache)
65 if err != nil {
66 t.Fatal(err)
67 }
68 return lib
69}
70
71func names(items []*Item) string {
72 var s []string
73 for _, it := range items {
74 s = append(s, it.Name)
75 }
76 return strings.Join(s, ",")
77}
78
79func slugs(as []*Album) string {
80 var s []string
81 for _, a := range as {
82 s = append(s, a.Slug)
83 }
84 return strings.Join(s, ",")
85}
86
87func TestScanSkipsAndSorts(t *testing.T) {
88 root := writeTree(t, map[string]string{
89 "tokyo/b.jpg": "x",
90 "tokyo/a.jpg": "x",
91 "tokyo/.hidden.jpg": "x",
92 "tokyo/notes.txt": "x",
93 "tokyo/sub/c.jpg": "x",
94 ".git/x.jpg": "x",
95 "img/x.jpg": "x",
96 "info/x.jpg": "x",
97 "_/x.jpg": "x",
98 "empty/notes.txt": "x",
99 "loose.jpg": "x",
100 })
101 f := &fakeFormat{meta: map[string]format.Meta{
102 "a.jpg": {Width: 300, Height: 200, Taken: day(2)},
103 "b.jpg": {Width: 300, Height: 200, Taken: day(1)},
104 }}
105 lib := scanWith(t, root, f, nil)
106
107 if got := slugs(lib.Albums); got != "tokyo" {
108 t.Fatalf("albums = %s", got)
109 }
110 a := lib.Album("tokyo")
111 if got := names(a.Items); got != "b.jpg,a.jpg" {
112 t.Fatalf("items = %s", got)
113 }
114 if a.Title != "tokyo" || a.Cover != a.Items[0] || a.Year() != "2025" {
115 t.Fatalf("title %q cover %v year %q", a.Title, a.Cover, a.Year())
116 }
117 if i, it := a.Item("a.jpg"); i != 1 || it.Path != filepath.Join(root, "tokyo", "a.jpg") {
118 t.Fatalf("Item(a.jpg) = %d, %+v", i, it)
119 }
120 if i, it := a.Item("nope.jpg"); i != -1 || it != nil {
121 t.Fatal("Item(nope.jpg) should be absent")
122 }
123 if lib.Album("nope") != nil {
124 t.Fatal("Album(nope) should be nil")
125 }
126}
127
128func TestScanAlbumOrg(t *testing.T) {
129 root := writeTree(t, map[string]string{
130 "tokyo/album.org": "#+title: Tokyo\n#+date: 2024-11\n#+cover: b.jpg\n#+sort: name\n\nA /quiet/ week.\n",
131 "tokyo/b.jpg": "x",
132 "tokyo/a.jpg": "x",
133 })
134 f := &fakeFormat{meta: map[string]format.Meta{
135 "a.jpg": {Width: 1, Height: 1, Taken: day(2)},
136 "b.jpg": {Width: 1, Height: 1, Taken: day(1)},
137 }}
138 a := scanWith(t, root, f, nil).Album("tokyo")
139 if a.Title != "Tokyo" || a.Date != "2024-11" || a.Year() != "2024" {
140 t.Fatalf("title %q date %q year %q", a.Title, a.Date, a.Year())
141 }
142 if got := names(a.Items); got != "a.jpg,b.jpg" {
143 t.Fatalf("sort name: items = %s", got)
144 }
145 if a.Cover.Name != "b.jpg" {
146 t.Fatalf("cover = %s", a.Cover.Name)
147 }
148 if !strings.Contains(string(a.Description), "<em>quiet</em>") {
149 t.Fatalf("description = %s", a.Description)
150 }
151}
152
153func TestScanAlbumOrdering(t *testing.T) {
154 root := writeTree(t, map[string]string{
155 "a/album.org": "#+order: 2\n",
156 "a/x.jpg": "x",
157 "b/album.org": "#+order: 1\n",
158 "b/x.jpg": "x",
159 "c/album.org": "#+date: 2024\n",
160 "c/x.jpg": "x",
161 "d/album.org": "#+date: 2025-06\n",
162 "d/x.jpg": "x",
163 "e/x.jpg": "x", // no album.org; newest item taken is day(1) of 2025-03
164 })
165 f := &fakeFormat{meta: map[string]format.Meta{"x.jpg": {Width: 1, Height: 1, Taken: day(1)}}}
166 if got := slugs(scanWith(t, root, f, nil).Albums); got != "b,a,d,e,c" {
167 t.Fatalf("albums = %s", got)
168 }
169}
170
171func TestScanTakenFallsBackToModTime(t *testing.T) {
172 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
173 mt := time.Date(2023, 5, 6, 7, 8, 9, 0, time.UTC)
174 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), mt, mt)
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)
178 }
179}
180
181func TestScanSkipsFailedMetadata(t *testing.T) {
182 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/bad.jpg": "x"})
183 a := scanWith(t, root, &fakeFormat{fail: map[string]bool{"bad.jpg": true}}, nil).Album("tokyo")
184 if got := names(a.Items); got != "a.jpg" {
185 t.Fatalf("items = %s", got)
186 }
187}
188
189func TestScanUsesMetaCache(t *testing.T) {
190 root := writeTree(t, map[string]string{"tokyo/a.jpg": "x", "tokyo/b.jpg": "x"})
191 cache, _ := OpenMetaCache(filepath.Join(t.TempDir(), "meta.json"))
192 f := &fakeFormat{}
193 scanWith(t, root, f, cache)
194 scanWith(t, root, f, cache)
195 if f.calls != 2 {
196 t.Fatalf("calls after two scans = %d, want 2", f.calls)
197 }
198 later := time.Now().Add(time.Hour)
199 os.Chtimes(filepath.Join(root, "tokyo", "a.jpg"), later, later)
200 scanWith(t, root, f, cache)
201 if f.calls != 3 {
202 t.Fatalf("calls after touch = %d, want 3", f.calls)
203 }
204}
205
206func TestScanAbout(t *testing.T) {
207 root := writeTree(t, map[string]string{"about.org": "Hello /there/.\n", "tokyo/a.jpg": "x"})
208 lib := scanWith(t, root, &fakeFormat{}, nil)
209 if !strings.Contains(string(lib.About), "<em>there</em>") {
210 t.Fatalf("About = %s", lib.About)
211 }
212 root2 := writeTree(t, map[string]string{"tokyo/a.jpg": "x"})
213 if lib := scanWith(t, root2, &fakeFormat{}, nil); lib.About != "" {
214 t.Fatalf("About without file = %q", lib.About)
215 }
216}
217
218func TestStore(t *testing.T) {
219 var s Store
220 if s.Load() != nil {
221 t.Fatal("empty store should load nil")
222 }
223 lib := &Library{}
224 s.Set(lib)
225 if s.Load() != lib {
226 t.Fatal("Load after Set")
227 }
228}