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 {
+2 -2
View File
@@ -25,7 +25,7 @@ import (
"time"
"hamlab/internal/priv"
"hamlab/internal/thumbs"
"hamlab/internal/media"
)
type Entry struct {
@@ -152,7 +152,7 @@ func entryOf(dir, name string, lst os.FileInfo) Entry {
e.Type = "other"
}
if e.Type == "file" || (e.Type == "link" && !e.Broken) {
e.Preview = thumbs.Kind(name)
e.Preview = media.Kind(name)
}
return e
}
@@ -1,4 +1,4 @@
package thumbs
package media
import (
"crypto/sha256"
+142
View File
@@ -0,0 +1,142 @@
package media
import (
"bytes"
"context"
"encoding/json"
"errors"
"os/exec"
"strconv"
"strings"
"hamlab/internal/priv"
)
// Stream is one stream of a media file, as ffprobe reports it.
type Stream struct {
Index int `json:"index"`
Type string `json:"type"` // video | audio
Codec string `json:"codec"`
Profile string `json:"profile,omitempty"`
PixFmt string `json:"pix_fmt,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
FPS float64 `json:"fps,omitempty"`
ColorTransfer string `json:"color_transfer,omitempty"` // smpte2084 / arib-std-b67 mean HDR
Channels int `json:"channels,omitempty"`
AttachedPic bool `json:"attached_pic,omitempty"`
}
// Info is what a file holds: the picture to take, the sound to go with it, and the whole.
type Info struct {
Container string `json:"container"` // "matroska,webm", "mov,mp4,m4a,3gp,3g2,mj2"
Duration float64 `json:"duration"`
BitRate int64 `json:"bit_rate,omitempty"` // bits per second, whole file
Size int64 `json:"size,omitempty"`
Video *Stream `json:"video,omitempty"` // the first real picture stream; cover art is not it
Audio *Stream `json:"audio,omitempty"` // the first sound stream
}
// HDR reports a picture that needs tone mapping to look right on an SDR screen.
func (i Info) HDR() bool {
return i.Video != nil && (i.Video.ColorTransfer == "smpte2084" || i.Video.ColorTransfer == "arib-std-b67")
}
type probeJSON struct {
Streams []struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Profile string `json:"profile"`
PixFmt string `json:"pix_fmt"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
AvgFrameRate string `json:"avg_frame_rate"`
ColorTransfer string `json:"color_transfer"`
Channels int `json:"channels"`
Disposition struct {
AttachedPic int `json:"attached_pic"`
} `json:"disposition"`
} `json:"streams"`
Format struct {
FormatName string `json:"format_name"`
Duration string `json:"duration"`
BitRate string `json:"bit_rate"`
Size string `json:"size"`
} `json:"format"`
}
// Inspect asks ffprobe what a file is. As root through sudo when asked, for a file only
// root may read. An error about the file itself is a DecodeError.
func Inspect(ctx context.Context, c Caps, path string, root bool) (Info, error) {
if c.FFprobe == "" {
return Info{}, errors.New("ffprobe is not installed")
}
argv := []string{c.FFprobe, "-v", "error", "-show_entries",
"stream=index,codec_type,codec_name,profile,pix_fmt,width,height,r_frame_rate,avg_frame_rate,color_transfer,channels,disposition:format=format_name,duration,bit_rate,size",
"-of", "json", path}
if root {
argv = priv.Wrap(argv...)
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
var errb bytes.Buffer
cmd.Stderr = &errb
out, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
return Info{}, ctx.Err()
}
msg := lastLine(errb.String())
if msg == "" {
msg = "ffprobe: " + err.Error()
}
return Info{}, &DecodeError{Msg: msg}
}
var pj probeJSON
if err := json.Unmarshal(out, &pj); err != nil {
return Info{}, &DecodeError{Msg: "ffprobe: unreadable answer"}
}
in := Info{Container: pj.Format.FormatName}
in.Duration, _ = strconv.ParseFloat(pj.Format.Duration, 64)
in.BitRate, _ = strconv.ParseInt(pj.Format.BitRate, 10, 64)
in.Size, _ = strconv.ParseInt(pj.Format.Size, 10, 64)
var art *Stream
for _, s := range pj.Streams {
st := &Stream{Index: s.Index, Type: s.CodecType, Codec: s.CodecName, Profile: s.Profile, PixFmt: s.PixFmt, Width: s.Width, Height: s.Height,
ColorTransfer: s.ColorTransfer, Channels: s.Channels, AttachedPic: s.Disposition.AttachedPic != 0}
st.FPS = fps(s.AvgFrameRate)
if st.FPS == 0 {
st.FPS = fps(s.RFrameRate)
}
switch {
case s.CodecType == "video" && st.AttachedPic:
if art == nil {
art = st
}
case s.CodecType == "video" && in.Video == nil:
in.Video = st
case s.CodecType == "audio" && in.Audio == nil:
in.Audio = st
}
}
if in.Video == nil {
in.Video = art // cover art is the only picture there is
}
return in, nil
}
// fps parses ffprobe's "60000/1001".
func fps(r string) float64 {
n, d, ok := strings.Cut(r, "/")
if !ok {
v, _ := strconv.ParseFloat(r, 64)
return v
}
nf, _ := strconv.ParseFloat(n, 64)
df, _ := strconv.ParseFloat(d, 64)
if df == 0 {
return 0
}
return nf / df
}
@@ -1,4 +1,4 @@
package thumbs
package media
import (
"fmt"
@@ -11,7 +11,7 @@
// the stream turns out unsupported. A hardware run that fails outright is retried on the
// CPU before the file is called undecodable. Images are always decoded on the CPU: one
// frame of a JPEG at a reduced DCT size costs less than the trip through a GPU.
package thumbs
package media
import (
"bytes"
@@ -1,4 +1,4 @@
package thumbs
package media
import (
"bytes"