// Package render produces and caches resized copies of library items. package render import ( "fmt" "log" "os" "path/filepath" "slices" "strconv" "time" "golang.org/x/sync/singleflight" "gitbay.org/cmc/gallery/internal/library" ) var Widths = []int{480, 960, 1600, 2400} func Allowed(w int) bool { return slices.Contains(Widths, w) } type Renderer struct { root string sem chan struct{} group singleflight.Group } func New(cacheDir string, concurrency int) *Renderer { return &Renderer{ root: filepath.Join(cacheDir, "img"), sem: make(chan struct{}, concurrency), } } func (r *Renderer) dir(it *library.Item) string { return filepath.Join(r.root, it.Album, it.Name, fmt.Sprintf("%d-%d", it.ModTime.UnixNano(), it.Size)) } func (r *Renderer) Path(it *library.Item, w int) string { return filepath.Join(r.dir(it), strconv.Itoa(w)+".jpg") } // Get returns the cached derivative for it at width w, generating it if needed. func (r *Renderer) Get(it *library.Item, w int) (string, error) { if !Allowed(w) { return "", fmt.Errorf("width %d not allowed", w) } p := r.Path(it, w) if _, err := os.Stat(p); err == nil { return p, nil } _, err, _ := r.group.Do(p, func() (any, error) { if _, err := os.Stat(p); err == nil { return nil, nil } r.sem <- struct{}{} defer func() { <-r.sem }() return nil, r.generate(it, w, p) }) if err != nil { return "", err } return p, nil } func (r *Renderer) generate(it *library.Item, w int, dst string) error { if it.Meta.Width > 0 && w > it.Meta.Width { w = it.Meta.Width } if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*") if err != nil { return err } defer os.Remove(tmp.Name()) if err := it.Format.Resize(it.Path, w, tmp); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), dst) } // pruneGrace keeps stale directories modified this recently, since a request // holding an older library snapshot may still be writing into them. This is // best-effort: a directory's mtime changes when entries are created or // renamed, not while a file is written, so a narrow window remains. const pruneGrace = 5 * time.Minute // Prune removes derivative directories for items no longer in lib or whose source changed. func (r *Renderer) Prune(lib *library.Library) error { keep := map[string]bool{} for _, a := range lib.Albums { for _, it := range a.Items { keep[r.dir(it)] = true } } versions, err := filepath.Glob(filepath.Join(r.root, "*", "*", "*")) if err != nil { return err } var firstErr error for _, v := range versions { if !keep[v] { if fi, err := os.Stat(v); err == nil && time.Since(fi.ModTime()) < pruneGrace { continue } if err := os.RemoveAll(v); err != nil { log.Printf("prune %s: %v", v, err) if firstErr == nil { firstErr = err } } } } return firstErr }