Commit 5ce5d2c9ae

5ce5d2c9ae9c5e5ad502ccd2e0751cb9a2ce4a61

parent: 512b3ef922

Verified · cmc

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

Add derivative renderer with disk cache

Layout: unified · split

go.mod +1
@@ -5,6 +5,7 @@ go 1.27.1
5require ( 5require (
6 github.com/fsnotify/fsnotify v1.10.1 6 github.com/fsnotify/fsnotify v1.10.1
7 github.com/niklasfasching/go-org v1.9.1 7 github.com/niklasfasching/go-org v1.9.1
8 golang.org/x/sync v0.23.0
8) 9)
9 10
10require ( 11require (
go.sum +2
@@ -6,5 +6,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
6github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 6github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
7golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 7golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
8golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 8golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
9golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
10golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
9golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 11golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
10golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 12golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
internal/render/render.go added +106
@@ -0,0 +1,106 @@
1// Package render produces and caches resized copies of library items.
2package render
3
4import (
5 "fmt"
6 "os"
7 "path/filepath"
8 "slices"
9 "strconv"
10
11 "golang.org/x/sync/singleflight"
12
13 "gitbay.org/cmc/gallery/internal/library"
14)
15
16var Widths = []int{480, 960, 1600, 2400}
17
18func Allowed(w int) bool { return slices.Contains(Widths, w) }
19
20type Renderer struct {
21 root string
22 sem chan struct{}
23 group singleflight.Group
24}
25
26func New(cacheDir string, concurrency int) *Renderer {
27 return &Renderer{
28 root: filepath.Join(cacheDir, "img"),
29 sem: make(chan struct{}, concurrency),
30 }
31}
32
33func (r *Renderer) dir(it *library.Item) string {
34 return filepath.Join(r.root, it.Album, it.Name, fmt.Sprintf("%d-%d", it.ModTime.UnixNano(), it.Size))
35}
36
37func (r *Renderer) Path(it *library.Item, w int) string {
38 return filepath.Join(r.dir(it), strconv.Itoa(w)+".jpg")
39}
40
41// Get returns the cached derivative for it at width w, generating it if needed.
42func (r *Renderer) Get(it *library.Item, w int) (string, error) {
43 if !Allowed(w) {
44 return "", fmt.Errorf("width %d not allowed", w)
45 }
46 p := r.Path(it, w)
47 if _, err := os.Stat(p); err == nil {
48 return p, nil
49 }
50 _, err, _ := r.group.Do(p, func() (any, error) {
51 if _, err := os.Stat(p); err == nil {
52 return nil, nil
53 }
54 r.sem <- struct{}{}
55 defer func() { <-r.sem }()
56 return nil, r.generate(it, w, p)
57 })
58 if err != nil {
59 return "", err
60 }
61 return p, nil
62}
63
64func (r *Renderer) generate(it *library.Item, w int, dst string) error {
65 if it.Meta.Width > 0 && w > it.Meta.Width {
66 w = it.Meta.Width
67 }
68 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
69 return err
70 }
71 tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*")
72 if err != nil {
73 return err
74 }
75 defer os.Remove(tmp.Name())
76 if err := it.Format.Resize(it.Path, w, tmp); err != nil {
77 tmp.Close()
78 return err
79 }
80 if err := tmp.Close(); err != nil {
81 return err
82 }
83 return os.Rename(tmp.Name(), dst)
84}
85
86// Prune removes derivative directories for items no longer in lib or whose source changed.
87func (r *Renderer) Prune(lib *library.Library) error {
88 keep := map[string]bool{}
89 for _, a := range lib.Albums {
90 for _, it := range a.Items {
91 keep[r.dir(it)] = true
92 }
93 }
94 versions, err := filepath.Glob(filepath.Join(r.root, "*", "*", "*"))
95 if err != nil {
96 return err
97 }
98 for _, v := range versions {
99 if !keep[v] {
100 if err := os.RemoveAll(v); err != nil {
101 return err
102 }
103 }
104 }
105 return nil
106}
internal/render/render_test.go added +166
@@ -0,0 +1,166 @@
1package render
2
3import (
4 "errors"
5 "io"
6 "os"
7 "path/filepath"
8 "sync"
9 "sync/atomic"
10 "testing"
11 "time"
12
13 "gitbay.org/cmc/gallery/internal/format"
14 "gitbay.org/cmc/gallery/internal/library"
15)
16
17type fakeFormat struct {
18 calls atomic.Int32
19 width atomic.Int32
20 fail bool
21}
22
23func (f *fakeFormat) Name() string { return "fake" }
24func (f *fakeFormat) Match(string) bool { return true }
25func (f *fakeFormat) Kind() format.Kind { return format.KindImage }
26func (f *fakeFormat) Metadata(string) (format.Meta, error) { return format.Meta{}, nil }
27func (f *fakeFormat) Resize(_ string, w int, dst io.Writer) error {
28 f.calls.Add(1)
29 f.width.Store(int32(w))
30 time.Sleep(20 * time.Millisecond)
31 io.WriteString(dst, "partial")
32 if f.fail {
33 return errors.New("magick failed")
34 }
35 io.WriteString(dst, "-done")
36 return nil
37}
38
39func item(f format.Format) *library.Item {
40 return &library.Item{
41 Album: "tokyo", Name: "a.jpg", Path: "/src/a.jpg",
42 ModTime: time.Unix(100, 0), Size: 42,
43 Meta: format.Meta{Width: 3000, Height: 2000},
44 Format: f,
45 }
46}
47
48func TestAllowed(t *testing.T) {
49 for _, w := range Widths {
50 if !Allowed(w) {
51 t.Errorf("%d should be allowed", w)
52 }
53 }
54 for _, w := range []int{0, 1, 961, 4000} {
55 if Allowed(w) {
56 t.Errorf("%d should not be allowed", w)
57 }
58 }
59}
60
61func TestGetGeneratesOnce(t *testing.T) {
62 f := &fakeFormat{}
63 r := New(t.TempDir(), 4)
64 it := item(f)
65
66 var wg sync.WaitGroup
67 for range 10 {
68 wg.Add(1)
69 go func() {
70 defer wg.Done()
71 if _, err := r.Get(it, 960); err != nil {
72 t.Error(err)
73 }
74 }()
75 }
76 wg.Wait()
77 p, err := r.Get(it, 960)
78 if err != nil {
79 t.Fatal(err)
80 }
81 if n := f.calls.Load(); n != 1 {
82 t.Fatalf("Resize calls = %d, want 1", n)
83 }
84 if b, _ := os.ReadFile(p); string(b) != "partial-done" {
85 t.Fatalf("content = %q", b)
86 }
87 if p != r.Path(it, 960) {
88 t.Fatalf("Get path %s != Path %s", p, r.Path(it, 960))
89 }
90}
91
92func TestGetRejectsWidth(t *testing.T) {
93 if _, err := New(t.TempDir(), 1).Get(item(&fakeFormat{}), 961); err == nil {
94 t.Fatal("want error")
95 }
96}
97
98func TestGetClampsToSource(t *testing.T) {
99 f := &fakeFormat{}
100 it := item(f)
101 it.Meta.Width = 700
102 if _, err := New(t.TempDir(), 1).Get(it, 960); err != nil {
103 t.Fatal(err)
104 }
105 if w := f.width.Load(); w != 700 {
106 t.Fatalf("resized to %d, want 700", w)
107 }
108}
109
110func TestGetFailureLeavesNothing(t *testing.T) {
111 f := &fakeFormat{fail: true}
112 r := New(t.TempDir(), 1)
113 it := item(f)
114 if _, err := r.Get(it, 480); err == nil {
115 t.Fatal("want error")
116 }
117 entries, _ := os.ReadDir(filepath.Dir(r.Path(it, 480)))
118 if len(entries) != 0 {
119 t.Fatalf("left behind: %v", entries)
120 }
121 r.Get(it, 480)
122 if n := f.calls.Load(); n != 2 {
123 t.Fatalf("failure was cached: calls = %d", n)
124 }
125}
126
127func TestPathChangesWithSource(t *testing.T) {
128 r := New("/cache", 1)
129 a := item(nil)
130 b := item(nil)
131 b.ModTime = time.Unix(200, 0)
132 if r.Path(a, 480) == r.Path(b, 480) {
133 t.Fatal("path must change with mtime")
134 }
135 want := filepath.Join("/cache", "img", "tokyo", "a.jpg", "100000000000-42", "480.jpg")
136 if got := r.Path(a, 480); got != want {
137 t.Fatalf("Path = %s, want %s", got, want)
138 }
139}
140
141func TestPrune(t *testing.T) {
142 f := &fakeFormat{}
143 r := New(t.TempDir(), 1)
144 cur := item(f)
145 old := item(f)
146 old.ModTime = time.Unix(50, 0)
147 gone := item(f)
148 gone.Name = "gone.jpg"
149 for _, it := range []*library.Item{cur, old, gone} {
150 if _, err := r.Get(it, 480); err != nil {
151 t.Fatal(err)
152 }
153 }
154 lib := &library.Library{Albums: []*library.Album{{Slug: "tokyo", Items: []*library.Item{cur}}}}
155 if err := r.Prune(lib); err != nil {
156 t.Fatal(err)
157 }
158 if _, err := os.Stat(r.Path(cur, 480)); err != nil {
159 t.Fatalf("current derivative removed: %v", err)
160 }
161 for _, it := range []*library.Item{old, gone} {
162 if _, err := os.Stat(filepath.Dir(r.Path(it, 480))); !os.IsNotExist(err) {
163 t.Fatalf("stale dir kept: %s", filepath.Dir(r.Path(it, 480)))
164 }
165 }
166}