diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index 4ed7f46..b8a3c82 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -41,7 +41,7 @@ import ( "hamlab/internal/store" "hamlab/internal/tasks" hterm "hamlab/internal/term" - "hamlab/internal/thumbs" + "hamlab/internal/media" "hamlab/internal/tlsauto" "hamlab/internal/updates" "hamlab/internal/web" @@ -84,7 +84,7 @@ func main() { case "fs": // privileged helper for the Files tab (run as root by the daemon) os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr)) case "thumb": // privileged helper for the Files tab's previews in root mode (run as root by the daemon) - os.Exit(thumbs.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr)) + os.Exit(media.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr)) case "space-walk": // helper for the Space tab: measure mountpoints, stream the tree back os.Exit(space.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr)) case "drm-clients": // privileged helper for the GPU readings: who has the GPU open (run as root by the daemon) diff --git a/internal/api/thumbs.go b/internal/api/thumbs.go index 996e3b5..6a219ac 100644 --- a/internal/api/thumbs.go +++ b/internal/api/thumbs.go @@ -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 { diff --git a/internal/files/files.go b/internal/files/files.go index ae51aad..f6d7031 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -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 } diff --git a/internal/thumbs/cache.go b/internal/media/cache.go similarity index 99% rename from internal/thumbs/cache.go rename to internal/media/cache.go index d3d9998..98db7f3 100644 --- a/internal/thumbs/cache.go +++ b/internal/media/cache.go @@ -1,4 +1,4 @@ -package thumbs +package media import ( "crypto/sha256" diff --git a/internal/media/inspect.go b/internal/media/inspect.go new file mode 100644 index 0000000..234ad14 --- /dev/null +++ b/internal/media/inspect.go @@ -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 +} diff --git a/internal/thumbs/owner.go b/internal/media/owner.go similarity index 97% rename from internal/thumbs/owner.go rename to internal/media/owner.go index 532a335..dface03 100644 --- a/internal/thumbs/owner.go +++ b/internal/media/owner.go @@ -1,4 +1,4 @@ -package thumbs +package media import ( "fmt" diff --git a/internal/thumbs/thumbs.go b/internal/media/thumbs.go similarity index 99% rename from internal/thumbs/thumbs.go rename to internal/media/thumbs.go index d18db22..b8ffa80 100644 --- a/internal/thumbs/thumbs.go +++ b/internal/media/thumbs.go @@ -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" diff --git a/internal/thumbs/thumbs_test.go b/internal/media/thumbs_test.go similarity index 99% rename from internal/thumbs/thumbs_test.go rename to internal/media/thumbs_test.go index 3f176ca..7a1ee44 100644 --- a/internal/thumbs/thumbs_test.go +++ b/internal/media/thumbs_test.go @@ -1,4 +1,4 @@ -package thumbs +package media import ( "bytes"