internal/render/render.go
121 lines · 2982 bytes
1// Package render produces and caches resized copies of library items.
2package render
3
4import (
5 "fmt"
6 "log"
7 "os"
8 "path/filepath"
9 "slices"
10 "strconv"
11 "time"
12
13 "golang.org/x/sync/singleflight"
14
15 "gitbay.org/cmc/gallery/internal/library"
16)
17
18var Widths = []int{480, 960, 1600, 2400}
19
20func Allowed(w int) bool { return slices.Contains(Widths, w) }
21
22type Renderer struct {
23 root string
24 sem chan struct{}
25 group singleflight.Group
26}
27
28func New(cacheDir string, concurrency int) *Renderer {
29 return &Renderer{
30 root: filepath.Join(cacheDir, "img"),
31 sem: make(chan struct{}, concurrency),
32 }
33}
34
35func (r *Renderer) dir(it *library.Item) string {
36 return filepath.Join(r.root, it.Album, it.Name, fmt.Sprintf("%d-%d", it.ModTime.UnixNano(), it.Size))
37}
38
39func (r *Renderer) Path(it *library.Item, w int) string {
40 return filepath.Join(r.dir(it), strconv.Itoa(w)+".jpg")
41}
42
43// Get returns the cached derivative for it at width w, generating it if needed.
44func (r *Renderer) Get(it *library.Item, w int) (string, error) {
45 if !Allowed(w) {
46 return "", fmt.Errorf("width %d not allowed", w)
47 }
48 p := r.Path(it, w)
49 if _, err := os.Stat(p); err == nil {
50 return p, nil
51 }
52 _, err, _ := r.group.Do(p, func() (any, error) {
53 if _, err := os.Stat(p); err == nil {
54 return nil, nil
55 }
56 r.sem <- struct{}{}
57 defer func() { <-r.sem }()
58 return nil, r.generate(it, w, p)
59 })
60 if err != nil {
61 return "", err
62 }
63 return p, nil
64}
65
66func (r *Renderer) generate(it *library.Item, w int, dst string) error {
67 if it.Meta.Width > 0 && w > it.Meta.Width {
68 w = it.Meta.Width
69 }
70 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
71 return err
72 }
73 tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*")
74 if err != nil {
75 return err
76 }
77 defer os.Remove(tmp.Name())
78 if err := it.Format.Resize(it.Path, w, tmp); err != nil {
79 tmp.Close()
80 return err
81 }
82 if err := tmp.Close(); err != nil {
83 return err
84 }
85 return os.Rename(tmp.Name(), dst)
86}
87
88// pruneGrace keeps stale directories modified this recently, since a request
89// holding an older library snapshot may still be writing into them. This is
90// best-effort: a directory's mtime changes when entries are created or
91// renamed, not while a file is written, so a narrow window remains.
92const pruneGrace = 5 * time.Minute
93
94// Prune removes derivative directories for items no longer in lib or whose source changed.
95func (r *Renderer) Prune(lib *library.Library) error {
96 keep := map[string]bool{}
97 for _, a := range lib.Albums {
98 for _, it := range a.Items {
99 keep[r.dir(it)] = true
100 }
101 }
102 versions, err := filepath.Glob(filepath.Join(r.root, "*", "*", "*"))
103 if err != nil {
104 return err
105 }
106 var firstErr error
107 for _, v := range versions {
108 if !keep[v] {
109 if fi, err := os.Stat(v); err == nil && time.Since(fi.ModTime()) < pruneGrace {
110 continue
111 }
112 if err := os.RemoveAll(v); err != nil {
113 log.Printf("prune %s: %v", v, err)
114 if firstErr == nil {
115 firstErr = err
116 }
117 }
118 }
119 }
120 return firstErr
121}