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:
co-authored by
Claude Fable 5.1
parent
beec9269fe
commit
af189b1fa1
+24
-24
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user