Files previews: the cache is one database file, <data dir>/thumbs.db

bbolt instead of a tree of hash-named JPEGs: pure Go, so the binary stays static; one
file per machine; an entry carries where the preview came from (engine, codec, size,
when) beside the picture, and a count rides along so the tab can say what it holds
without walking anything. A miss never creates the file, so a machine nobody browses
with previews on never has one; a cache that cannot be opened is logged and previews
are still made and served, just not kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 16:04:12 -07:00
co-authored by Claude Fable 5.1
parent 51c718e8e3
commit e198471f8c
7 changed files with 259 additions and 68 deletions
+26 -12
View File
@@ -1,7 +1,7 @@
package api
// Previews for the Files tab: GET /api/files/thumb hands back one small JPEG of an image or
// a video, made by internal/thumbs and kept under <data dir>/thumbs/. The work is bounded
// a video, made by internal/thumbs and kept in <data dir>/thumbs.db. The work is bounded
// two ways — a handful of ffmpegs at once, and one per file however many rows ask for it —
// because a folder of a thousand photos asks for every row the moment previews go on.
//
@@ -32,7 +32,7 @@ const thumbTimeout = 30 * time.Second
const capsTTL = 10 * time.Minute // how long a probe is believed; a refresh after an install or a group change does not wait
type thumbSvc struct {
cache thumbs.Cache
cache *thumbs.Cache
self string
sem chan struct{}
mu sync.Mutex
@@ -61,7 +61,7 @@ func newThumbSvc(dataDir, self string) *thumbSvc {
if n > 4 {
n = 4
}
return &thumbSvc{cache: thumbs.Cache{Dir: filepath.Join(dataDir, "thumbs")}, self: self, sem: make(chan struct{}, n), caps: map[bool]*capsEntry{}, calls: map[string]*thumbCall{}}
return &thumbSvc{cache: thumbs.NewCache(filepath.Join(dataDir, "thumbs.db")), self: self, sem: make(chan struct{}, n), caps: map[bool]*capsEntry{}, calls: map[string]*thumbCall{}}
}
// capsFor is what previews can be made with, probed once and kept for a while.
@@ -208,7 +208,9 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
var de *thumbs.DecodeError
switch {
case errors.As(err, &de):
_ = s.thumbs.cache.PutFailure(key, de.Msg)
if err := s.thumbs.cache.Put(key, thumbs.Entry{Path: p, Failure: de.Msg}); err != nil {
log.Printf("thumbs: cache: %v", err)
}
log.Printf("thumbs: %s: %s", p, de.Msg)
jsonErr(w, 422, de.Msg)
case errors.Is(err, context.DeadlineExceeded):
@@ -221,21 +223,26 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
}
return
}
if err := s.thumbs.cache.Put(key, res.JPEG); err != nil {
log.Printf("thumbs: cache %s: %v", key[:8], err)
if err := s.thumbs.cache.Put(key, thumbs.Entry{Path: p, Engine: res.Engine, Codec: res.Codec, Width: res.Width, Height: res.Height, JPEG: res.JPEG}); err != nil {
log.Printf("thumbs: cache: %v", err)
}
writeThumb(w, key, res.JPEG, res.Engine)
}
// serveCachedThumb answers from the cache when it can. A cache that cannot be read is a
// miss: the preview is made again rather than refused.
func (s *Server) serveCachedThumb(w http.ResponseWriter, key string) bool {
data, why, found := s.thumbs.cache.Get(key)
e, found, err := s.thumbs.cache.Get(key)
if err != nil {
log.Printf("thumbs: cache: %v", err)
}
switch {
case !found:
return false
case data == nil:
jsonErr(w, 422, why)
case e.Failure != "":
jsonErr(w, 422, e.Failure)
default:
writeThumb(w, key, data, "cache")
writeThumb(w, key, e.JPEG, "cache ("+e.Engine+")")
}
return true
}
@@ -271,7 +278,13 @@ type thumbStatus struct {
Install *thumbInstall `json:"install,omitempty"` // ffmpeg is missing and this can put it on
GPUFix bool `json:"gpu_fix,omitempty"` // a render node is there but closed to the daemon's user, and this can open it
User string `json:"user"`
CacheDir string `json:"cache_dir"`
Cache thumbCache `json:"cache"`
}
type thumbCache struct {
Path string `json:"path"`
Entries int64 `json:"entries"`
Bytes int64 `json:"bytes"`
}
type thumbInstall struct {
@@ -287,7 +300,8 @@ func (s *Server) thumbsStatus(w http.ResponseWriter, r *http.Request) {
return
}
caps, err := s.thumbs.capsFor(r.Context(), root, r.URL.Query().Get("refresh") == "1")
st := thumbStatus{GPUs: []thumbs.GPU{}, User: s.user, CacheDir: s.thumbs.cache.Dir}
st := thumbStatus{GPUs: []thumbs.GPU{}, User: s.user, Cache: thumbCache{Path: s.thumbs.cache.Path}}
st.Cache.Entries, st.Cache.Bytes = s.thumbs.cache.Stats()
if root {
st.User = "root"
}