Files
hamlab/internal/thumbs/cache.go
T
Mitchell-JohnsonandClaude Fable 5.1 f8c9479e93 Files: previews — a toggle that shows a thumbnail per image and video, made by ffmpeg on the machine's GPU where it has one, cached under the data dir
A previews button on the Files tab turns the icon column into a 256 px thumbnail for
every image and video row, loaded as rows scroll near. internal/thumbs probes what the
machine can do rather than assuming it: ffmpeg and ffprobe, every /dev/dri/renderD*
opened and exercised, vainfo's decode profiles per node where it exists. A video goes to
the first GPU that lists its codec, with ffmpeg's own software fallback behind it and a
CPU retry behind that; images decode on the CPU, JPEGs at a reduced DCT size. Previews
are kept under <data dir>/thumbs/ keyed by path, size and mtime, with undecodable files
remembered too. Root mode makes them through the same root helper the file reads use.
Where ffmpeg is missing the tab offers to install it; where a render node exists but is
closed to the daemon's user, it offers a drop-in adding the group and a restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 15:20:27 -07:00

79 lines
2.3 KiB
Go

package thumbs
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strconv"
)
// Cache keeps finished previews on disk, under Ham Lab's own data directory: one JPEG per
// (path, size, mtime), so a file that changes gets a new preview and an unchanged one is
// never decoded twice. A file ffmpeg could not decode is remembered the same way, with
// ffmpeg's reason beside it, so a folder of odd files does not re-run ffmpeg on every
// visit. Nothing expires: a preview is a few kilobytes, and only files somebody actually
// scrolled past are ever made.
type Cache struct{ Dir string }
// Key identifies one file's preview. "v1" is the preview format: bump it to retire every
// cached preview at once.
func Key(path string, size, mtime int64) string {
h := sha256.Sum256([]byte("v1\x00" + path + "\x00" + strconv.FormatInt(size, 10) + "\x00" + strconv.FormatInt(mtime, 10)))
return hex.EncodeToString(h[:])
}
func (c Cache) file(key, ext string) string { return filepath.Join(c.Dir, key[:2], key+ext) }
// Get returns the preview, or the remembered reason it could not be made.
func (c Cache) Get(key string) (jpeg []byte, failure string, found bool) {
if len(key) < 2 {
return nil, "", false
}
if b, err := os.ReadFile(c.file(key, ".jpg")); err == nil && len(b) > 0 {
return b, "", true
}
if b, err := os.ReadFile(c.file(key, ".err")); err == nil {
return nil, string(b), true
}
return nil, "", false
}
// Put keeps a preview; PutFailure keeps the reason there is none.
func (c Cache) Put(key string, jpeg []byte) error {
if len(jpeg) == 0 {
return errors.New("empty preview")
}
return c.write(c.file(key, ".jpg"), jpeg)
}
func (c Cache) PutFailure(key, reason string) error {
return c.write(c.file(key, ".err"), []byte(reason))
}
// write lands the file whole or not at all: a reader never sees a half-written JPEG.
func (c Cache) write(dst string, b []byte) error {
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".tmp-*")
if err != nil {
return err
}
if _, err := tmp.Write(b); err != nil {
tmp.Close()
os.Remove(tmp.Name())
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmp.Name())
return err
}
if err := os.Rename(tmp.Name(), dst); err != nil {
os.Remove(tmp.Name())
return err
}
return nil
}