Vellum/internal/playback/handlers.go
mithral 67f145a9b1 Waves 7-8: playback engine selection, and missing files you can see
Wave 7 - engines, chosen per file by the client's own capabilities
- internal/engine: a registry. html5 is built in and declares a CONSERVATIVE
  browser baseline (h264/vp8/vp9/av1, deliberately no hevc and no mpeg4, because
  that is what was measured). mpv, vlc and mdk are registered as native engines
  declaring the broad support those libraries really have.
- The server never declares a file playable. It reports what is in the file and
  matches it against the engines the CLIENT declares. Proven:
      engines=["html5"]       -> playable=false engine=null reason=vcodec:hevc
      engines=["html5","mpv"] -> playable=true  engine=mpv
  Same file, different answer, because the client offered a different engine.
  That is what makes adding a native client a registration rather than a rewrite.
- internal/playback: GET /playback/engines and POST /playback/resolve, behind
  auth and the same grant chokepoint. An unreadable item is 404, not 403.
- web/src/lib/capabilities.ts: uses navigator.mediaCapabilities.decodingInfo(),
  which returns {supported, smooth, powerEfficient}. That is real client-hardware
  detection and it distinguishes "cannot decode" from "can decode but it will
  stutter and drain the battery". Feature-detected; degrades to the server
  baseline rather than throwing.
- VideoPlayer consults the server as an ADVISORY only. The element remains the
  source of truth: the server's note appears first, then the element's own
  confirmation replaces it once videoWidth proves the answer. Verified on HEVC —
  "Playback may not work here" at 0-906ms, "Video cannot play here" from ~1208ms.

Wave 8 - missing files: visibility and an explicit purge
- migrations/0011 adds missing_since to media_items and media_files.
- It is written only on the TRANSITION into missing, so the age says when the
  file first vanished rather than the last rescan. Proven byte-identical across a
  rescan, and cleared when a file is restored.
- GET /api/v1/missing (grant-scoped) and POST /api/v1/libraries/{id}/purge
  (admin only). The purge deletes only items that are missing AND have no
  present file, and its cascade into progress/item_people/item_views/game_saves
  is enumerated in a comment — that cascade is exactly why deletion is never
  automatic. Proven: 19 items -> 18, only the missing one, the non-missing item
  untouched and its files intact, second purge returns 0.
- MissingFiles.svelte in the Manage nav: title, kind, library, path, human age,
  and a per-library Purge behind a confirmation that names the count and says
  progress cannot be regenerated.

Coordinator wired playback.NewHandlers into main.go (the one line agents were
told to leave alone).

Verified end to end: 17 packages pass, gofmt/vet clean, svelte-check 0/0, and
every claim above checked against the running server.
2026-10-11 14:36:55 -04:00

118 lines
4 KiB
Go

// Package playback exposes the server side of direct-play selection: the list
// of playback engines and the decision of whether a particular item can be
// direct-played by the engines a client declares.
//
// Vellum does not transcode. The server reports what is in each file (codec
// facts written by the scanner) and matches it against the client's declared
// engines; the client is the only party that knows its own hardware. The web
// app declares "html5"; a native client declares "mpv"/"vlc"/"mdk" as well.
package playback
import (
"database/sql"
"errors"
"log/slog"
"net/http"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/engine"
"github.com/mithral/vellum/internal/httpx"
)
// Handlers serves the playback selection endpoints.
type Handlers struct {
db *sql.DB
log *slog.Logger
auth *auth.Authenticator
}
// NewHandlers builds the playback HTTP layer.
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
return &Handlers{db: db, log: log, auth: a}
}
// Register mounts the routes. Both require an authenticated user; the resolve
// endpoint additionally scopes to items the caller may read.
func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/playback/engines", h.auth.Require(h.handleEngines))
mux.Handle("POST /api/v1/playback/resolve", h.auth.Require(h.handleResolve))
}
// handleEngines lists every engine the server knows about. A client uses this to
// show what it could offer and to name engines in a resolve request.
func (h *Handlers) handleEngines(w http.ResponseWriter, r *http.Request, u auth.User) {
httpx.WriteJSON(w, http.StatusOK, map[string]any{"engines": engine.All()})
}
// handleResolve answers whether the caller's client can direct-play an item.
//
// The body is {"item_id":N,"engines":["html5",...]}. The item's codec facts
// (container, vcodec, acodec) are read from the database and handed to
// engine.Resolve together with the client's declared engines.
func (h *Handlers) handleResolve(w http.ResponseWriter, r *http.Request, u auth.User) {
var req struct {
ItemID int64 `json:"item_id"`
Engines []string `json:"engines"`
}
if err := httpx.DecodeJSON(r, &req); err != nil {
httpx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if req.ItemID <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid item_id")
return
}
// The same access-control chokepoint the media handlers use: load the item's
// library, then ask CanAccessLibrary. An item the user cannot read is a 404,
// not a 403, so its existence is not disclosed.
var libraryID int64
err := h.db.QueryRowContext(r.Context(),
`SELECT library_id FROM media_items WHERE id = ?`, req.ItemID).Scan(&libraryID)
if errors.Is(err, sql.ErrNoRows) {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
if err != nil {
h.fail(w, "load item", err)
return
}
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, libraryID)
if err != nil {
h.fail(w, "check access", err)
return
}
if !allowed {
httpx.WriteError(w, http.StatusNotFound, "item not found")
return
}
// The first file carries the codec facts the player will try. An item with
// no file cannot be played, but is still a 200 with a resolution so the UI
// has one shape to render.
var container, vcodec, acodec string
err = h.db.QueryRowContext(r.Context(),
`SELECT container, vcodec, acodec FROM media_files WHERE item_id = ? ORDER BY id LIMIT 1`,
req.ItemID).Scan(&container, &vcodec, &acodec)
if errors.Is(err, sql.ErrNoRows) {
httpx.WriteJSON(w, http.StatusOK, engine.Resolution{
Playable: false,
Reason: "nofile",
Message: "This item has no file to play.",
})
return
}
if err != nil {
h.fail(w, "load file facts", err)
return
}
res := engine.Resolve(engine.Facts(container, vcodec, acodec), req.Engines)
httpx.WriteJSON(w, http.StatusOK, res)
}
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
h.log.Error(what, "err", err)
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
}