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}