internal/thumbs is internal/media, with Inspect: what a file holds, from ffprobe

The package was named for its first job; it now also decides what a file is for the
Files tab (image, video, audio) and reads its streams, which the viewer will need. Inspect
is the richer probe — codec, profile, pixel format, size, frame rate, HDR transfer, the
audio beside it, container and duration — run as root through sudo when asked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-11 16:54:15 -07:00
co-authored by Claude Fable 5.1
parent beec9269fe
commit af189b1fa1
8 changed files with 174 additions and 32 deletions
+24 -24
View File
@@ -24,7 +24,7 @@ import (
"time"
"hamlab/internal/priv"
"hamlab/internal/thumbs"
"hamlab/internal/media"
)
const thumbSize = 256
@@ -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 *media.Cache
self string
sem chan struct{}
mu sync.Mutex
@@ -42,14 +42,14 @@ type thumbSvc struct {
type capsEntry struct {
mu sync.Mutex
caps thumbs.Caps
caps media.Caps
err error
at time.Time
}
type thumbCall struct {
done chan struct{}
res thumbs.Result
res media.Result
err error
}
@@ -61,11 +61,11 @@ func newThumbSvc(dataDir, self string) *thumbSvc {
if n > 4 {
n = 4
}
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{}}
return &thumbSvc{cache: media.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.
func (t *thumbSvc) capsFor(ctx context.Context, root, refresh bool) (thumbs.Caps, error) {
func (t *thumbSvc) capsFor(ctx context.Context, root, refresh bool) (media.Caps, error) {
t.mu.Lock()
e := t.caps[root]
if e == nil {
@@ -79,9 +79,9 @@ func (t *thumbSvc) capsFor(ctx context.Context, root, refresh bool) (thumbs.Caps
return e.caps, e.err
}
if root {
e.caps, e.err = thumbs.ProbeRoot(ctx, t.self)
e.caps, e.err = media.ProbeRoot(ctx, t.self)
} else {
e.caps, e.err = thumbs.Probe(ctx), nil
e.caps, e.err = media.Probe(ctx), nil
}
e.at = time.Now()
mode := "as " + map[bool]string{false: "the daemon", true: "root"}[root]
@@ -97,7 +97,7 @@ func (t *thumbSvc) capsFor(ctx context.Context, root, refresh bool) (thumbs.Caps
}
// describeCaps is the one line that says how previews get made here.
func describeCaps(c thumbs.Caps) string {
func describeCaps(c media.Caps) string {
if len(c.GPUs) == 0 {
return "CPU only"
}
@@ -117,7 +117,7 @@ func describeCaps(c thumbs.Caps) string {
}
// make produces a preview, sharing the work when the same file is asked for twice at once.
func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (thumbs.Result, error) {
func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (media.Result, error) {
t.mu.Lock()
if c, ok := t.calls[key]; ok {
t.mu.Unlock()
@@ -125,7 +125,7 @@ func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (thumb
case <-c.done:
return c.res, c.err
case <-ctx.Done():
return thumbs.Result{}, ctx.Err()
return media.Result{}, ctx.Err()
}
}
c := &thumbCall{done: make(chan struct{})}
@@ -153,11 +153,11 @@ func (t *thumbSvc) make(ctx context.Context, root bool, key, path string) (thumb
c.err = err
return c.res, c.err
}
o := thumbs.Opts{Size: thumbSize, Timeout: thumbTimeout}
o := media.Opts{Size: thumbSize, Timeout: thumbTimeout}
if root {
c.res, c.err = thumbs.MakeRoot(wctx, t.self, caps, path, o)
c.res, c.err = media.MakeRoot(wctx, t.self, caps, path, o)
} else {
c.res, c.err = thumbs.Make(wctx, caps, path, o)
c.res, c.err = media.Make(wctx, caps, path, o)
}
return c.res, c.err
}
@@ -175,13 +175,13 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if thumbs.Kind(p) == "" {
if media.Kind(p) == "" {
jsonErr(w, 415, "no preview for this kind of file")
return
}
root := r.URL.Query().Get("sudo") == "1"
if mt, sz, ok := parseV(r.URL.Query().Get("v")); ok {
key := thumbs.Key(p, sz, mt)
key := media.Key(p, sz, mt)
if r.Header.Get("If-None-Match") == `"`+key+`"` {
w.WriteHeader(304)
return
@@ -199,16 +199,16 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 400, "not a regular file")
return
}
key := thumbs.Key(p, in.Size, in.Mtime)
key := media.Key(p, in.Size, in.Mtime)
if s.serveCachedThumb(w, key) {
return
}
res, err := s.thumbs.make(r.Context(), root, key, p)
if err != nil {
var de *thumbs.DecodeError
var de *media.DecodeError
switch {
case errors.As(err, &de):
if err := s.thumbs.cache.Put(key, thumbs.Entry{Path: p, Failure: de.Msg}); err != nil {
if err := s.thumbs.cache.Put(key, media.Entry{Path: p, Failure: de.Msg}); err != nil {
log.Printf("thumbs: cache: %v", err)
}
log.Printf("thumbs: %s: %s", p, de.Msg)
@@ -223,7 +223,7 @@ func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
}
return
}
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 {
if err := s.thumbs.cache.Put(key, media.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)
@@ -272,8 +272,8 @@ type thumbStatus struct {
Engine string `json:"engine"` // gpu | cpu | ""
Summary string `json:"summary"`
FFmpeg string `json:"ffmpeg,omitempty"`
GPUs []thumbs.GPU `json:"gpus"`
Unusable []thumbs.GPU `json:"unusable,omitempty"`
GPUs []media.GPU `json:"gpus"`
Unusable []media.GPU `json:"unusable,omitempty"`
Error string `json:"error,omitempty"`
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
@@ -300,7 +300,7 @@ 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, Cache: thumbCache{Path: s.thumbs.cache.Path}}
st := thumbStatus{GPUs: []media.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"
@@ -312,7 +312,7 @@ func (s *Server) thumbsStatus(w http.ResponseWriter, r *http.Request) {
}
st.FFmpeg, st.GPUs, st.Unusable, st.Error = caps.Version, caps.GPUs, caps.Unusable, caps.Error
if st.GPUs == nil {
st.GPUs = []thumbs.GPU{}
st.GPUs = []media.GPU{}
}
st.OK = caps.Error == ""
switch {