Files: previews — a toggle that shows a thumbnail per image and video, made by ffmpeg on the machine's GPU where it has one, cached under the data dir
A previews button on the Files tab turns the icon column into a 256 px thumbnail for every image and video row, loaded as rows scroll near. internal/thumbs probes what the machine can do rather than assuming it: ffmpeg and ffprobe, every /dev/dri/renderD* opened and exercised, vainfo's decode profiles per node where it exists. A video goes to the first GPU that lists its codec, with ffmpeg's own software fallback behind it and a CPU retry behind that; images decode on the CPU, JPEGs at a reduced DCT size. Previews are kept under <data dir>/thumbs/ keyed by path, size and mtime, with undecodable files remembered too. Root mode makes them through the same root helper the file reads use. Where ffmpeg is missing the tab offers to install it; where a render node exists but is closed to the daemon's user, it offers a drop-in adding the group and a restart. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
e7034cd478
commit
f8c9479e93
@@ -79,6 +79,7 @@ type Server struct {
|
||||
virt *incus.Manager
|
||||
fsLocal files.FS
|
||||
fsSudo files.FS // nil without passwordless sudo
|
||||
thumbs *thumbSvc
|
||||
nodes *nodes.Manager
|
||||
fingerprint string
|
||||
version string
|
||||
@@ -99,6 +100,7 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
||||
self = "hamlab"
|
||||
}
|
||||
s.space = space.New(self, filepath.Join(cfg.DataDir(), "space"))
|
||||
s.thumbs = newThumbSvc(cfg.DataDir(), self)
|
||||
s.hostName() // read it once now so the first request doesn't have to
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
s.user = u.Username
|
||||
@@ -301,6 +303,10 @@ func (s *Server) routes() *http.ServeMux {
|
||||
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
|
||||
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
|
||||
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
|
||||
m.HandleFunc("GET /api/files/thumb", s.auth(s.filesThumb))
|
||||
m.HandleFunc("GET /api/files/thumbs", s.auth(s.thumbsStatus))
|
||||
m.HandleFunc("POST /api/files/thumbs/install", s.auth(s.thumbsInstall))
|
||||
m.HandleFunc("POST /api/files/thumbs/gpu-access", s.auth(s.thumbsGPUAccess))
|
||||
m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport))
|
||||
m.HandleFunc("GET /api/network", s.auth(s.networkOverview))
|
||||
m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport))
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
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
|
||||
// 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.
|
||||
//
|
||||
// Root mode (?sudo=1) makes the preview through the same root helper the file reads use,
|
||||
// so a file only root can read gets a preview too, and the probe runs as root as well: root
|
||||
// opens every render node, whatever groups the daemon's user has.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hamlab/internal/priv"
|
||||
"hamlab/internal/thumbs"
|
||||
)
|
||||
|
||||
const thumbSize = 256
|
||||
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
|
||||
self string
|
||||
sem chan struct{}
|
||||
mu sync.Mutex
|
||||
caps map[bool]*capsEntry // keyed by root
|
||||
calls map[string]*thumbCall
|
||||
}
|
||||
|
||||
type capsEntry struct {
|
||||
mu sync.Mutex
|
||||
caps thumbs.Caps
|
||||
err error
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type thumbCall struct {
|
||||
done chan struct{}
|
||||
res thumbs.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func newThumbSvc(dataDir, self string) *thumbSvc {
|
||||
n := runtime.NumCPU() / 2 // each ffmpeg is itself threaded; previews must not take the whole machine
|
||||
if n < 2 {
|
||||
n = 2
|
||||
}
|
||||
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{}}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.mu.Lock()
|
||||
e := t.caps[root]
|
||||
if e == nil {
|
||||
e = &capsEntry{}
|
||||
t.caps[root] = e
|
||||
}
|
||||
t.mu.Unlock()
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !refresh && !e.at.IsZero() && time.Since(e.at) < capsTTL {
|
||||
return e.caps, e.err
|
||||
}
|
||||
if root {
|
||||
e.caps, e.err = thumbs.ProbeRoot(ctx, t.self)
|
||||
} else {
|
||||
e.caps, e.err = thumbs.Probe(ctx), nil
|
||||
}
|
||||
e.at = time.Now()
|
||||
mode := "as " + map[bool]string{false: "the daemon", true: "root"}[root]
|
||||
switch {
|
||||
case e.err != nil:
|
||||
log.Printf("thumbs: probe %s failed: %v", mode, e.err)
|
||||
case e.caps.Error != "":
|
||||
log.Printf("thumbs: %s: %s", mode, e.caps.Error)
|
||||
default:
|
||||
log.Printf("thumbs: %s: ffmpeg %s, %s", mode, e.caps.Version, describeCaps(e.caps))
|
||||
}
|
||||
return e.caps, e.err
|
||||
}
|
||||
|
||||
// describeCaps is the one line that says how previews get made here.
|
||||
func describeCaps(c thumbs.Caps) string {
|
||||
if len(c.GPUs) == 0 {
|
||||
return "CPU only"
|
||||
}
|
||||
parts := make([]string, 0, len(c.GPUs))
|
||||
for _, g := range c.GPUs {
|
||||
name := g.Name
|
||||
if name == "" {
|
||||
name = g.Device
|
||||
}
|
||||
p := name + " (" + g.API
|
||||
if len(g.Codecs) > 0 {
|
||||
p += ": " + strings.Join(g.Codecs, ", ")
|
||||
}
|
||||
parts = append(parts, p+")")
|
||||
}
|
||||
return "GPU " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.mu.Lock()
|
||||
if c, ok := t.calls[key]; ok {
|
||||
t.mu.Unlock()
|
||||
select {
|
||||
case <-c.done:
|
||||
return c.res, c.err
|
||||
case <-ctx.Done():
|
||||
return thumbs.Result{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
c := &thumbCall{done: make(chan struct{})}
|
||||
t.calls[key] = c
|
||||
t.mu.Unlock()
|
||||
defer func() {
|
||||
t.mu.Lock()
|
||||
delete(t.calls, key)
|
||||
t.mu.Unlock()
|
||||
close(c.done)
|
||||
}()
|
||||
// The work itself runs on a context of its own: the first requester going away must not
|
||||
// cancel a preview three other rows are waiting for.
|
||||
wctx, cancel := context.WithTimeout(context.Background(), thumbTimeout+5*time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case t.sem <- struct{}{}:
|
||||
defer func() { <-t.sem }()
|
||||
case <-ctx.Done():
|
||||
c.err = ctx.Err()
|
||||
return c.res, c.err
|
||||
}
|
||||
caps, err := t.capsFor(wctx, root, false)
|
||||
if err != nil {
|
||||
c.err = err
|
||||
return c.res, c.err
|
||||
}
|
||||
o := thumbs.Opts{Size: thumbSize, Timeout: thumbTimeout}
|
||||
if root {
|
||||
c.res, c.err = thumbs.MakeRoot(wctx, t.self, caps, path, o)
|
||||
} else {
|
||||
c.res, c.err = thumbs.Make(wctx, caps, path, o)
|
||||
}
|
||||
return c.res, c.err
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
// filesThumb: GET /api/files/thumb?path=…&v=<mtime>-<size>[&sudo=1]. v is the client's idea of
|
||||
// the file, from its listing; it makes a cache hit free of a stat, and a changed file a new URL.
|
||||
func (s *Server) filesThumb(w http.ResponseWriter, r *http.Request) {
|
||||
fsys, ok := s.fsFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, ok := cleanQuery(w, r, "path")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if thumbs.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)
|
||||
if r.Header.Get("If-None-Match") == `"`+key+`"` {
|
||||
w.WriteHeader(304)
|
||||
return
|
||||
}
|
||||
if s.serveCachedThumb(w, key) {
|
||||
return
|
||||
}
|
||||
}
|
||||
in, err := fsys.Stat(r.Context(), p)
|
||||
if err != nil {
|
||||
fsErr(w, err)
|
||||
return
|
||||
}
|
||||
if in.Type == "dir" || in.Type == "other" || in.Broken {
|
||||
jsonErr(w, 400, "not a regular file")
|
||||
return
|
||||
}
|
||||
key := thumbs.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
|
||||
switch {
|
||||
case errors.As(err, &de):
|
||||
_ = s.thumbs.cache.PutFailure(key, de.Msg)
|
||||
log.Printf("thumbs: %s: %s", p, de.Msg)
|
||||
jsonErr(w, 422, de.Msg)
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
log.Printf("thumbs: %s: timed out", p)
|
||||
jsonErr(w, 504, "preview timed out")
|
||||
case errors.Is(err, context.Canceled):
|
||||
jsonErr(w, 499, "cancelled")
|
||||
default:
|
||||
jsonErr(w, 503, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.thumbs.cache.Put(key, res.JPEG); err != nil {
|
||||
log.Printf("thumbs: cache %s: %v", key[:8], err)
|
||||
}
|
||||
writeThumb(w, key, res.JPEG, res.Engine)
|
||||
}
|
||||
|
||||
func (s *Server) serveCachedThumb(w http.ResponseWriter, key string) bool {
|
||||
data, why, found := s.thumbs.cache.Get(key)
|
||||
switch {
|
||||
case !found:
|
||||
return false
|
||||
case data == nil:
|
||||
jsonErr(w, 422, why)
|
||||
default:
|
||||
writeThumb(w, key, data, "cache")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeThumb(w http.ResponseWriter, key string, jpeg []byte, engine string) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(jpeg)))
|
||||
w.Header().Set("Cache-Control", "private, max-age=604800")
|
||||
w.Header().Set("ETag", `"`+key+`"`)
|
||||
w.Header().Set("X-Hamlab-Thumb", engine)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
_, _ = w.Write(jpeg)
|
||||
}
|
||||
|
||||
func parseV(v string) (mtime, size int64, ok bool) {
|
||||
a, b, found := strings.Cut(v, "-")
|
||||
if !found {
|
||||
return 0, 0, false
|
||||
}
|
||||
mtime, err1 := strconv.ParseInt(a, 10, 64)
|
||||
size, err2 := strconv.ParseInt(b, 10, 64)
|
||||
return mtime, size, err1 == nil && err2 == nil
|
||||
}
|
||||
|
||||
type thumbStatus struct {
|
||||
OK bool `json:"ok"`
|
||||
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"`
|
||||
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
|
||||
User string `json:"user"`
|
||||
CacheDir string `json:"cache_dir"`
|
||||
}
|
||||
|
||||
type thumbInstall struct {
|
||||
PM string `json:"pm"`
|
||||
Package string `json:"package"`
|
||||
}
|
||||
|
||||
// thumbsStatus: GET /api/files/thumbs[?sudo=1][&refresh=1] — how previews get made on this machine.
|
||||
func (s *Server) thumbsStatus(w http.ResponseWriter, r *http.Request) {
|
||||
root := r.URL.Query().Get("sudo") == "1"
|
||||
if root && s.fsSudo == nil {
|
||||
jsonErr(w, 403, "root access needs passwordless sudo on the host")
|
||||
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}
|
||||
if root {
|
||||
st.User = "root"
|
||||
}
|
||||
if err != nil {
|
||||
st.Error = err.Error()
|
||||
jsonOK(w, 200, st)
|
||||
return
|
||||
}
|
||||
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.OK = caps.Error == ""
|
||||
switch {
|
||||
case !st.OK:
|
||||
if pkg := ffmpegPackage(s.updates.PM()); pkg != "" && priv.Available() {
|
||||
st.Install = &thumbInstall{PM: s.updates.PM(), Package: pkg}
|
||||
}
|
||||
st.Summary = st.Error
|
||||
case len(caps.GPUs) > 0:
|
||||
st.Engine = "gpu"
|
||||
st.Summary = describeCaps(caps) + " · images on the CPU"
|
||||
default:
|
||||
st.Engine = "cpu"
|
||||
st.Summary = "CPU only"
|
||||
}
|
||||
if !root {
|
||||
for _, g := range caps.Unusable {
|
||||
if g.Permission && gpuFixPossible() {
|
||||
st.GPUFix = true
|
||||
}
|
||||
}
|
||||
}
|
||||
jsonOK(w, 200, st)
|
||||
}
|
||||
|
||||
// ffmpegPackage is what to install for ffmpeg on a package manager. Fedora ships it as
|
||||
// ffmpeg-free; the others under its own name.
|
||||
func ffmpegPackage(pm string) string {
|
||||
switch pm {
|
||||
case "":
|
||||
return ""
|
||||
case "dnf":
|
||||
if b, _ := os.ReadFile("/etc/os-release"); strings.Contains(string(b), "ID=fedora") {
|
||||
return "ffmpeg-free"
|
||||
}
|
||||
}
|
||||
return "ffmpeg"
|
||||
}
|
||||
|
||||
const hamlabUnit = "/etc/systemd/system/hamlab.service"
|
||||
|
||||
// gpuFixPossible: the daemon is an ordinary user under systemd, with root to hand.
|
||||
func gpuFixPossible() bool {
|
||||
if os.Geteuid() == 0 || !priv.Available() {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(hamlabUnit)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// thumbsInstall: POST /api/files/thumbs/install — put ffmpeg on with the machine's package manager.
|
||||
func (s *Server) thumbsInstall(w http.ResponseWriter, r *http.Request) {
|
||||
pkg := ffmpegPackage(s.updates.PM())
|
||||
if pkg == "" {
|
||||
jsonErr(w, 400, "no supported package manager on this machine")
|
||||
return
|
||||
}
|
||||
t, err := s.updates.Install([]string{pkg})
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("thumbs: installing %s as task %s (from %s)", pkg, t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
|
||||
// gpuAccessScript gives hamlab.service the groups that own the render nodes, by a drop-in,
|
||||
// then restarts it: a running process cannot gain a group.
|
||||
const gpuAccessScript = `set -e
|
||||
unit=` + hamlabUnit + `
|
||||
[ -f "$unit" ] || { echo "no $unit: this machine does not run Ham Lab under systemd"; exit 1; }
|
||||
groups=""
|
||||
for g in render video; do
|
||||
if getent group "$g" >/dev/null 2>&1; then groups="$groups $g"; fi
|
||||
done
|
||||
[ -n "$groups" ] || { echo "neither a render nor a video group exists here"; exit 1; }
|
||||
mkdir -p "$unit.d"
|
||||
printf '[Service]\nSupplementaryGroups=%s\n' "${groups# }" > "$unit.d/gpu.conf"
|
||||
echo "hamlab.service: supplementary groups${groups} ($unit.d/gpu.conf)"
|
||||
systemctl daemon-reload
|
||||
echo 'Ham Lab restarts in 3 seconds to pick up its new groups; the page reconnects by itself.'
|
||||
systemd-run --quiet --on-active=3 --unit=hamlab-gpu-restart systemctl restart hamlab.service
|
||||
`
|
||||
|
||||
// thumbsGPUAccess: POST /api/files/thumbs/gpu-access — open the render nodes to the daemon.
|
||||
func (s *Server) thumbsGPUAccess(w http.ResponseWriter, r *http.Request) {
|
||||
if !gpuFixPossible() {
|
||||
jsonErr(w, 400, "nothing to do: Ham Lab is root, has no root, or is not under systemd here")
|
||||
return
|
||||
}
|
||||
full := priv.Wrap("sh", "-c", gpuAccessScript)
|
||||
t, err := s.tasks.Start("thumbs:gpu", "give Ham Lab access to the GPU", exec.Command(full[0], full[1:]...))
|
||||
if err != nil {
|
||||
jsonErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("thumbs: GPU access drop-in as task %s, restart follows (from %s)", t.ID, clientIP(r))
|
||||
jsonOK(w, 202, t.Snapshot(false))
|
||||
}
|
||||
Reference in New Issue
Block a user