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.
118 lines
4 KiB
Go
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")
|
|
}
|