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.
This commit is contained in:
mithral 2026-10-11 14:36:55 -04:00
parent 915f390156
commit 67f145a9b1
15 changed files with 1774 additions and 17 deletions

View file

@ -26,6 +26,7 @@ import (
"github.com/mithral/vellum/internal/modules/ebooks"
"github.com/mithral/vellum/internal/modules/games"
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
"github.com/mithral/vellum/internal/playback"
"github.com/mithral/vellum/internal/search"
"github.com/mithral/vellum/migrations"
)
@ -104,6 +105,10 @@ func run(cfg core.Config, log *slog.Logger) error {
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
games.NewHandlers(db, log, authenticator).Register(api.Mux())
search.NewHandlers(db, log, authenticator).Register(api.Mux())
// Playback engine registry + selection. Vellum never transcodes, so the
// server reports what is inside a file and matches it against the engines the
// CLIENT declares — a browser offers "html5", a native client can offer mpv.
playback.NewHandlers(db, log, authenticator).Register(api.Mux())
// Scans run on the queue rather than inside the request: a real library
// takes longer than any sensible timeout, and a hanging request is

203
internal/engine/engine.go Normal file
View file

@ -0,0 +1,203 @@
// Package engine describes the playback engines a client can offer and decides
// whether a particular file can be direct-played by that client.
//
// Vellum never transcodes: the server is a headless, GPU-less Xeon, and measured
// against real files, playability turned out to be a property of the CLIENT, not
// the file. Chrome on Linux plays an MKV carrying H.264 perfectly well, yet
// cannot decode an MP4 carrying HEVC at all. So the server's job is only to
// report what is inside each file (see internal/probe) and to match it against
// what the client says it has.
//
// This package is the registry of those engines and the matcher. The web app's
// only engine is "html5"; native clients (mpv, libvlc, MDK) declare their own.
// Adding a native engine later is a registration, not a re-architecture — the
// native libraries themselves can only live inside a native client, never in a
// browser.
package engine
import (
"sort"
"strings"
)
// Engine kinds. A "browser" engine is an HTML5 media element and is bound by
// what the browser's decoders support; a "native" engine links a real media
// library (mpv/libmpv, libvlc, MDK) and can therefore live only inside a native
// client.
const (
KindBrowser = "browser"
KindNative = "native"
)
// HTML5ID is the built-in browser engine every client can be assumed to have.
const HTML5ID = "html5"
// Engine declares one playback backend and the media it can open.
//
// The JSON tags are the frozen EngineSupport contract in web/src/lib/api.ts and
// must not change without changing that file too.
type Engine struct {
// ID is the stable identifier clients pass in their declared engine list.
ID string `json:"id"`
// Name is the human label shown in the UI.
Name string `json:"name"`
// Kind is KindBrowser or KindNative.
Kind string `json:"kind"`
// Containers are lowercase extensions, without a leading dot ("mkv").
Containers []string `json:"containers"`
// VCodecs are ffprobe video codec names ("h264", "hevc", ...).
VCodecs []string `json:"vcodecs"`
// ACodecs are ffprobe audio codec names ("aac", "opus", ...).
ACodecs []string `json:"acodecs"`
}
// builtins are the engines Vellum knows out of the box. html5 is the browser
// baseline; the native engines are registered so a native client can name them,
// and are never selectable from a browser.
var builtins = []Engine{
{
ID: HTML5ID,
Name: "HTML5",
Kind: KindBrowser,
// A deliberately CONSERVATIVE browser baseline. The real answer comes
// from the client: MediaCapabilities (see web/src/lib/capabilities.ts)
// can distinguish "cannot decode at all" from "decodes but will
// stutter", and only the client's GPU knows that. This list is what the
// server can honestly assume of a browser with no other information.
//
// Note what is absent: hevc/h265 and mpeg4 are measured as undecodable
// in Chrome, so they must not be claimed here. Container support is
// broad because mkv/mov play fine when the video track is h264.
Containers: []string{
"mp4", "m4v", "mov", "mkv", "webm",
"mp3", "m4a", "m4b", "aac", "flac", "ogg", "opus", "wav",
},
VCodecs: []string{"h264", "vp8", "vp9", "av1"},
// pcm appears under several ffprobe names (pcm_s16le, ...); the aliases
// are listed so a WAV is not rejected over a naming detail.
ACodecs: []string{"aac", "mp3", "opus", "vorbis", "flac", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8"},
},
{
// mpv (libmpv) handles essentially every ffmpeg demuxer and decoder.
// Registered for NATIVE clients only: a browser cannot link libmpv.
ID: "mpv",
Name: "mpv",
Kind: KindNative,
Containers: []string{
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob",
},
VCodecs: []string{
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo",
},
ACodecs: []string{
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8",
},
},
{
// libvlc is similarly broad. Registered for NATIVE clients only.
ID: "vlc",
Name: "VLC",
Kind: KindNative,
Containers: []string{
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob", "asf", "rm",
},
VCodecs: []string{
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo", "rv40",
},
ACodecs: []string{
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8", "cook",
},
},
{
// MDK is a cross-platform multimedia SDK (ffmpeg-based). Registered for
// NATIVE clients only.
ID: "mdk",
Name: "MDK",
Kind: KindNative,
Containers: []string{
"mkv", "mp4", "m4v", "mov", "webm", "avi", "wmv", "flv",
"ts", "m2ts", "mpg", "mpeg", "ogv", "ogm", "3gp", "vob",
},
VCodecs: []string{
"h264", "avc1", "hevc", "h265", "mpeg4", "mpeg2video", "mpeg1video",
"vc1", "wmv3", "av1", "vp8", "vp9", "theora", "prores", "dvvideo",
},
ACodecs: []string{
"aac", "mp3", "opus", "vorbis", "flac", "ac3", "eac3", "dts",
"truehd", "alac", "wma", "pcm", "pcm_s16le", "pcm_s24le", "pcm_f32le", "pcm_u8",
},
},
}
// registry is built once from builtins. Read-only after init, so All/Get need no
// lock; if runtime registration is ever added it must grow a mutex.
var registry = func() map[string]Engine {
m := make(map[string]Engine, len(builtins))
for _, e := range builtins {
m[e.ID] = e
}
return m
}()
// All returns every registered engine, sorted by ID so the response is stable.
func All() []Engine {
out := make([]Engine, 0, len(registry))
for _, e := range registry {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Get looks up one engine by ID. The second result is false for an unknown ID,
// which callers treat as "this client has no such engine" rather than an error.
func Get(id string) (Engine, bool) {
e, ok := registry[strings.ToLower(strings.TrimSpace(id))]
return e, ok
}
// Facts packs a file's codec facts into the positional slice Resolve expects:
// container, video codec, audio codec. An empty video codec means there is no
// video track (audio, or a comic).
func Facts(container, vcodec, acodec string) []string {
return []string{container, vcodec, acodec}
}
// canPlay reports whether an engine can direct-play a file with these facts.
//
// A missing codec ("" for an audio-only file's video track) is not a blocker:
// there is simply no such track to decode. Container is always required.
func (e Engine) canPlay(container, vcodec, acodec string) bool {
if !contains(e.Containers, container) {
return false
}
if vcodec != "" && !contains(e.VCodecs, vcodec) {
return false
}
if acodec != "" && !contains(e.ACodecs, acodec) {
return false
}
return true
}
// contains reports membership, case-insensitively, so "H264" and "h264" match
// and a stray leading dot on a container extension does not.
func contains(list []string, want string) bool {
want = strings.ToLower(strings.TrimSpace(want))
want = strings.TrimPrefix(want, ".")
if want == "" {
return false
}
for _, v := range list {
if strings.ToLower(strings.TrimSpace(v)) == want {
return true
}
}
return false
}

View file

@ -0,0 +1,245 @@
package engine
import (
"encoding/json"
"strings"
"testing"
)
// TestRegistryHasBuiltins pins the engines a client can name out of the box.
func TestRegistryHasBuiltins(t *testing.T) {
for _, id := range []string{HTML5ID, "mpv", "vlc", "mdk"} {
if _, ok := Get(id); !ok {
t.Errorf("engine %q is not registered", id)
}
}
if _, ok := Get("nope"); ok {
t.Error("Get returned an unknown engine")
}
// Get is case/space tolerant so a client's casing never matters.
if _, ok := Get(" HTML5 "); !ok {
t.Error("Get should trim and lower-case the id")
}
}
// TestHTML5BaselineIsHonest guards the measured facts: the browser baseline must
// NOT claim HEVC or mpeg4, which Chrome cannot decode, and must not claim a
// container it does not handle.
func TestHTML5BaselineIsHonest(t *testing.T) {
html5, _ := Get(HTML5ID)
if contains(html5.VCodecs, "hevc") || contains(html5.VCodecs, "h265") {
t.Error("html5 baseline claims HEVC, which browsers cannot decode")
}
if contains(html5.VCodecs, "mpeg4") {
t.Error("html5 baseline claims mpeg4, which browsers cannot decode")
}
if !contains(html5.VCodecs, "h264") || !contains(html5.VCodecs, "vp9") || !contains(html5.VCodecs, "av1") {
t.Errorf("html5 baseline is missing a measured-playable codec: %v", html5.VCodecs)
}
if !contains(html5.Containers, "mkv") {
t.Error("html5 baseline must accept mkv (h264-in-mkv plays)")
}
if contains(html5.Containers, "avi") {
t.Error("html5 baseline must not claim avi")
}
}
// TestEngineJSONMatchesContract pins the frozen wire shape (web/src/lib/api.ts).
func TestEngineJSONMatchesContract(t *testing.T) {
e, _ := Get(HTML5ID)
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatal(err)
}
for _, key := range []string{"id", "name", "kind", "containers", "vcodecs", "acodecs"} {
if _, ok := raw[key]; !ok {
t.Errorf("engine JSON is missing %q", key)
}
}
if len(raw) != 6 {
t.Errorf("engine JSON has %d keys, want exactly 6: %s", len(raw), b)
}
}
func TestResolve(t *testing.T) {
tests := []struct {
name string
facts []string
engines []string
playable bool
engine string // expected engine id, or "" for nil
reason string
msgSubstr string // must appear in Message when not playable
}{
{
name: "mp4 h264 aac plays in html5",
facts: Facts("mp4", "h264", "aac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "mkv h264 plays despite the container",
facts: Facts("mkv", "h264", "aac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "mp4 hevc aac is not playable",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5"},
playable: false,
reason: "vcodec:hevc",
msgSubstr: "HEVC",
},
{
name: "avi mpeg4 is a container blocker",
facts: Facts("avi", "mpeg4", "mp3"),
engines: []string{"html5"},
playable: false,
reason: "container:avi",
msgSubstr: "AVI",
},
{
name: "unsupported audio codec",
facts: Facts("mp4", "h264", "dts"),
engines: []string{"html5"},
playable: false,
reason: "acodec:dts",
msgSubstr: "DTS",
},
{
name: "audio-only mp3 is playable",
facts: Facts("mp3", "", "mp3"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "audio-only flac is playable",
facts: Facts("flac", "", "flac"),
engines: []string{"html5"},
playable: true,
engine: "html5",
},
{
name: "audio-only dts is not playable",
facts: Facts("m4a", "", "dts"),
engines: []string{"html5"},
playable: false,
reason: "acodec:dts",
msgSubstr: "DTS",
},
{
name: "empty engine list falls back to html5 baseline",
facts: Facts("mp4", "h264", "aac"),
engines: nil,
playable: true,
engine: "html5",
},
{
name: "empty engine list still rejects hevc",
facts: Facts("mp4", "hevc", "aac"),
engines: nil,
playable: false,
reason: "vcodec:hevc",
msgSubstr: "mpv",
},
{
name: "native engine changes the answer for hevc",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5", "mpv"},
playable: true,
engine: "mpv",
},
{
name: "native engine handles avi",
facts: Facts("avi", "mpeg4", "mp3"),
engines: []string{"html5", "vlc"},
playable: true,
engine: "vlc",
},
{
name: "client ordering wins among capable engines",
facts: Facts("mkv", "h264", "aac"),
engines: []string{"mpv", "html5"},
playable: true,
engine: "mpv",
},
{
name: "capable engine beats an earlier incapable one",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"html5", "vlc", "mpv"},
playable: true,
engine: "vlc",
},
{
name: "unknown engines are skipped, reason uses fallback",
facts: Facts("mp4", "hevc", "aac"),
engines: []string{"bogus"},
playable: false,
reason: "vcodec:hevc",
msgSubstr: "mpv",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Resolve(tt.facts, tt.engines)
if got.Playable != tt.playable {
t.Fatalf("playable = %v, want %v (reason %q)", got.Playable, tt.playable, got.Reason)
}
if tt.playable {
if got.Engine == nil || *got.Engine != tt.engine {
t.Errorf("engine = %v, want %q", got.Engine, tt.engine)
}
if got.Reason != "" || got.Message != "" {
t.Errorf("playable result carried reason=%q message=%q", got.Reason, got.Message)
}
return
}
if got.Engine != nil {
t.Errorf("engine = %q, want null", *got.Engine)
}
if got.Reason != tt.reason {
t.Errorf("reason = %q, want %q", got.Reason, tt.reason)
}
if tt.msgSubstr != "" && !strings.Contains(got.Message, tt.msgSubstr) {
t.Errorf("message = %q, want it to mention %q", got.Message, tt.msgSubstr)
}
if strings.Contains(strings.ToLower(got.Message), "you ") {
t.Errorf("message blames the user: %q", got.Message)
}
})
}
}
// TestResolutionJSONEngineNull pins that a non-playable answer serialises
// engine as JSON null, exactly as PlaybackResolution declares.
func TestResolutionJSONEngineNull(t *testing.T) {
got := Resolve(Facts("mp4", "hevc", "aac"), []string{"html5"})
b, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
var raw struct {
Playable bool `json:"playable"`
Engine json.RawMessage `json:"engine"`
Reason string `json:"reason"`
Message string `json:"message"`
}
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatal(err)
}
if string(raw.Engine) != "null" {
t.Errorf("engine = %s, want null", raw.Engine)
}
if raw.Playable || raw.Reason != "vcodec:hevc" || raw.Message == "" {
t.Errorf("unexpected resolution JSON: %s", b)
}
}

156
internal/engine/resolve.go Normal file
View file

@ -0,0 +1,156 @@
package engine
import (
"fmt"
"strings"
)
// Resolution is the answer to "can this client play this file, and with what?".
//
// The JSON shape is frozen by PlaybackResolution in web/src/lib/api.ts:
// playable, engine (null when nothing can), reason, message. Reason is
// machine-readable ("vcodec:hevc"); message is the human text the UI shows.
type Resolution struct {
Playable bool `json:"playable"`
// Engine is the ID of the engine that should play the file, or nil when
// nothing the client declared can. It is a pointer so it serialises to JSON
// null, matching the frozen contract.
Engine *string `json:"engine"`
// Reason is empty when playable; otherwise a machine-readable cause such as
// "vcodec:hevc", "container:avi" or "acodec:dts".
Reason string `json:"reason"`
// Message is human text for the UI. Empty when playable.
Message string `json:"message"`
}
// Resolve decides whether a client can direct-play a file.
//
// fileFacts is the positional slice produced by Facts: container, video codec,
// audio codec. clientEngineIDs is the list of engines the CLIENT declared, in
// the client's own preference order.
//
// Semantics:
// - Only engines the client declared are considered. If the client declared
// none (an older client, or one that has not implemented capability
// detection), we fall back to the html5 baseline rather than refusing to
// answer.
// - An engine that can play the file always beats one that cannot; among
// capable engines the client's ordering wins, so a native client can say
// "try mpv first".
// - A file with no video codec (audio-only) is playable as soon as its audio
// codec is supported.
func Resolve(fileFacts, clientEngineIDs []string) Resolution {
container, vcodec, acodec := splitFacts(fileFacts)
ids := clientEngineIDs
if len(ids) == 0 {
// No declared engines: fall back to the conservative html5 baseline so
// the caller still gets a useful verdict. The client is always free to
// refine it (see web/src/lib/capabilities.ts).
ids = []string{HTML5ID}
}
// First declared capable engine wins.
for _, id := range ids {
e, ok := Get(id)
if !ok {
continue
}
if e.canPlay(container, vcodec, acodec) {
engineID := e.ID
return Resolution{Playable: true, Engine: &engineID}
}
}
// Nothing the client declared can play it. Explain the blocker using the
// client's first *known* engine — its stated preference — falling back to
// html5 when the client named only engines we do not know.
ref, ok := Get(firstKnown(ids))
if !ok {
ref, _ = Get(HTML5ID)
}
reason := blocker(ref, container, vcodec, acodec)
return Resolution{
Playable: false,
Reason: reason,
Message: message(reason, container, vcodec, acodec),
}
}
// splitFacts unpacks the positional fileFacts slice, tolerating a short slice.
func splitFacts(fileFacts []string) (container, vcodec, acodec string) {
get := func(i int) string {
if i < len(fileFacts) {
return fileFacts[i]
}
return ""
}
return get(0), get(1), get(2)
}
// firstKnown returns the first ID the registry knows, or "" if none.
func firstKnown(ids []string) string {
for _, id := range ids {
if _, ok := Get(id); ok {
return id
}
}
return ""
}
// blocker names the first fact the reference engine cannot handle, in the order
// container, video codec, audio codec. The reason is machine-readable; the
// caller turns it into a human message.
func blocker(e Engine, container, vcodec, acodec string) string {
switch {
case container != "" && !contains(e.Containers, container):
return "container:" + strings.ToLower(strings.TrimPrefix(container, "."))
case vcodec != "" && !contains(e.VCodecs, vcodec):
return "vcodec:" + strings.ToLower(vcodec)
case acodec != "" && !contains(e.ACodecs, acodec):
return "acodec:" + strings.ToLower(acodec)
default:
// canPlay was false, so one of the checks above should have matched.
return "unsupported"
}
}
// message renders the human explanation for a non-playable file. It names the
// offending codec, never blames the user, and — when a registered native engine
// could play the file — points at it, because the browser is not the only way
// in and a native client may well be on hand.
func message(reason, container, vcodec, acodec string) string {
var lead string
switch {
case strings.HasPrefix(reason, "container:"):
lead = fmt.Sprintf("This file is a %s, which this browser cannot open directly.",
strings.ToUpper(strings.TrimPrefix(container, ".")))
case strings.HasPrefix(reason, "vcodec:"):
lead = fmt.Sprintf("This file uses %s video, which this browser cannot decode.",
strings.ToUpper(vcodec))
case strings.HasPrefix(reason, "acodec:"):
lead = fmt.Sprintf("This file uses %s audio, which this browser cannot decode.",
strings.ToUpper(acodec))
default:
lead = "This browser cannot play this file directly."
}
if native := nativeEngineFor(container, vcodec, acodec); native != "" {
lead += fmt.Sprintf(" A native player such as %s can play this.", native)
}
lead += " Vellum does not transcode."
return lead
}
// nativeEngineFor returns the ID of a registered native engine that can play
// the file, or "" if none can. It is used only to make the message honest about
// the ways the file could still be played. mpv is named first because it is the
// engine Vellum's native client reaches for; the others follow.
func nativeEngineFor(container, vcodec, acodec string) string {
for _, id := range []string{"mpv", "vlc", "mdk"} {
if e, ok := Get(id); ok && e.Kind == KindNative && e.canPlay(container, vcodec, acodec) {
return e.ID
}
}
return ""
}

View file

@ -42,10 +42,12 @@ func (h *Handlers) Register(mux *http.ServeMux) {
mux.Handle("GET /api/v1/libraries/{id}", h.auth.Require(h.handleGet))
mux.Handle("GET /api/v1/libraries/{id}/items", h.auth.Require(h.handleListItems))
mux.Handle("GET /api/v1/libraries/{id}/scan/status", h.auth.RequireAuth(http.HandlerFunc(h.handleScanStatus)))
mux.Handle("GET /api/v1/missing", h.auth.Require(h.handleMissing))
mux.Handle("POST /api/v1/libraries", h.auth.RequireAdmin(http.HandlerFunc(h.handleCreate)))
mux.Handle("DELETE /api/v1/libraries/{id}", h.auth.RequireAdmin(http.HandlerFunc(h.handleDelete)))
mux.Handle("POST /api/v1/libraries/{id}/scan", h.auth.RequireAdmin(http.HandlerFunc(h.handleScan)))
mux.Handle("POST /api/v1/libraries/{id}/purge", h.auth.RequireAdmin(http.HandlerFunc(h.handlePurge)))
}
// handleMediaTypes is public: the UI needs the list before login to render.
@ -262,8 +264,66 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "limit": limit})
}
// handleMissing lists the files the scanner has flagged missing in the
// libraries the caller may read. An optional ?library_id= narrows the result
// to a single library, and an id outside the caller's visible set simply
// yields nothing rather than leaking that the library exists.
func (h *Handlers) handleMissing(w http.ResponseWriter, r *http.Request, u auth.User) {
ids, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
if err != nil {
h.fail(w, "visible libraries", err)
return
}
if v := r.URL.Query().Get("library_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil || id <= 0 {
httpx.WriteError(w, http.StatusBadRequest, "invalid library_id")
return
}
ids = narrowToLibrary(ids, id)
}
entries, err := ListMissing(r.Context(), h.db, ids)
if err != nil {
h.fail(w, "list missing", err)
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)})
}
// handlePurge deletes a library's genuinely missing items on explicit admin
// request. It is admin-only and never runs automatically: the delete cascades
// into progress, which cannot be regenerated. See PurgeMissing for the full
// cascade and the exact "actually missing" condition.
func (h *Handlers) handlePurge(w http.ResponseWriter, r *http.Request) {
id, ok := pathInt(w, r, "id")
if !ok {
return
}
n, err := PurgeMissing(r.Context(), h.db, id)
if err != nil {
h.fail(w, "purge missing", err)
return
}
h.log.Info("purged missing items", "library", id, "count", n)
httpx.WriteJSON(w, http.StatusOK, map[string]any{"purged": n})
}
// ── helpers ────────────────────────────────────────────────────────────
// narrowToLibrary keeps only id when the caller asks for one library. An id
// outside the visible set yields an empty set, so a non-admin cannot probe a
// library they cannot read.
func narrowToLibrary(ids []int64, id int64) []int64 {
for _, v := range ids {
if v == id {
return []int64{id}
}
}
return []int64{}
}
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")

View file

@ -118,6 +118,107 @@ func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) {
return n, err
}
// MissingEntry is one missing file, surfaced so an admin can see what the
// scanner has flagged and decide whether to purge it.
//
// It carries both the file (file_id) and its owning item (item_id) because the
// file is what actually vanished, while the item is what a purge deletes. A
// multi-file item (an audiobook of tracks) produces one entry per missing file.
type MissingEntry struct {
ItemID int64 `json:"item_id"`
FileID int64 `json:"file_id"`
LibraryID int64 `json:"library_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Path string `json:"path"`
MissingSince string `json:"missing_since"`
}
// ListMissing returns the missing files in the given libraries, grouped by
// library and then by title.
//
// It is keyed on media_files because a missing file is the thing the scanner
// saw disappear; an item whose whole group is gone has each of its files
// flagged, so every missing item still appears. ids is the caller's visible
// set, resolved by the auth layer — an empty set yields nothing, never "all",
// which is the same rule ListVisible follows.
func ListMissing(ctx context.Context, db *sql.DB, ids []int64) ([]MissingEntry, error) {
if len(ids) == 0 {
return []MissingEntry{}, nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
rows, err := db.QueryContext(ctx, `
SELECT i.id, f.id, i.library_id, i.kind, i.title, f.path,
CASE WHEN f.missing_since <> '' THEN f.missing_since ELSE i.missing_since END
FROM media_files f
JOIN media_items i ON i.id = f.item_id
WHERE f.missing = 1 AND i.library_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY i.library_id, i.sort_title, f.path`, args...)
if err != nil {
return nil, fmt.Errorf("library: list missing: %w", err)
}
defer rows.Close()
out := []MissingEntry{}
for rows.Next() {
var e MissingEntry
if err := rows.Scan(&e.ItemID, &e.FileID, &e.LibraryID, &e.Kind, &e.Title, &e.Path, &e.MissingSince); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
// PurgeMissing deletes the items in a library that are genuinely missing:
// flagged missing and with no file left present. It returns how many items were
// removed.
//
// This is the ONLY place media rows are deleted on purpose, and it is an
// explicit admin action rather than part of a scan because the delete is a
// one-way door. Deleting a media_items row cascades (foreign_keys is on) into:
//
// - media_files.item_id — the files themselves
// - progress.item_id — watch/read positions, which cannot be
// regenerated; this is why a scan never deletes
// - progress.file_id — via the cascaded media_files rows
// - item_people.item_id — cast and author credits
// - provider_ids.item_id — external metadata matches
// - metadata_fetch.item_id — provider attempt history
// - assets.item_id — covers, thumbnails, extracted pages
// - collection_items.item_id — collection membership
// - favorites.item_id
// - item_views.item_id — per-user view counts
// - game_saves.item_id — emulator save states
// - child media_items — the parent_id self-reference
//
// The WHERE clause is deliberately narrow: an item is purged only when it is
// flagged missing AND has no present file, so an item missing one track while
// its other tracks remain on disk is never touched.
func PurgeMissing(ctx context.Context, db *sql.DB, libraryID int64) (int64, error) {
res, err := db.ExecContext(ctx, `
DELETE FROM media_items
WHERE library_id = ?
AND missing = 1
AND NOT EXISTS (
SELECT 1 FROM media_files f
WHERE f.item_id = media_items.id AND f.missing = 0
)`, libraryID)
if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err)
}
return n, nil
}
// ── helpers ────────────────────────────────────────────────────────────
func resolveRoot(rootPath string) (string, error) {

View file

@ -109,3 +109,194 @@ func TestListVisibleNeverMeansAll(t *testing.T) {
t.Errorf("ListVisible(nil) returned %d libraries; empty set must mean none", len(libs))
}
}
func TestMissingSinceLifecycle(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("first Scan: %v", err)
}
// A present file has no missing timestamp.
var since string
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != "" {
t.Errorf("present file missing_since = %q, want empty", since)
}
// Delete b.epub: it goes missing and the timestamp is recorded.
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("missing Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since == "" {
t.Fatal("missing_since was not set on the transition into missing")
}
first := since
// A rescan must not refresh the age.
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("rescan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != first {
t.Errorf("missing_since reset on rescan: %q -> %q", first, since)
}
// ListMissing surfaces the file with its age.
entries, err := ListMissing(ctx, db, []int64{lib.ID})
if err != nil {
t.Fatalf("ListMissing: %v", err)
}
if len(entries) != 1 {
t.Fatalf("ListMissing returned %d entries, want 1", len(entries))
}
e := entries[0]
if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID {
t.Errorf("unexpected missing entry: %+v", e)
}
if e.MissingSince != first {
t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first)
}
// Restore the file: missing_since must be cleared, not left behind.
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("restore Scan: %v", err)
}
if err := db.QueryRowContext(ctx,
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
t.Fatal(err)
}
if since != "" {
t.Errorf("missing_since after restore = %q, want empty", since)
}
if got, _ := ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 {
t.Errorf("ListMissing after restore returned %d entries, want 0", len(got))
}
}
func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
db := testDB(t)
ctx := context.Background()
root := t.TempDir()
// Two audiobooks, each a folder of tracks.
writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1")
writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2")
writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1")
writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2")
lib, err := Create(ctx, db, "Audiobooks", "audiobooks", root)
if err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("Scan: %v", err)
}
// A second library with its own missing item, to prove purge is scoped.
otherRoot := t.TempDir()
writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x")
other, err := Create(ctx, db, "Other", "ebooks", otherRoot)
if err != nil {
t.Fatalf("Create other: %v", err)
}
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
t.Fatalf("Scan other: %v", err)
}
if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
t.Fatalf("Scan other (missing): %v", err)
}
// Remove one track from Book A: the item is not missing, only that file.
// Remove Book B entirely: the item and both its files are missing.
if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil {
t.Fatal(err)
}
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
t.Fatalf("second Scan: %v", err)
}
entries, err := ListMissing(ctx, db, []int64{lib.ID})
if err != nil {
t.Fatalf("ListMissing: %v", err)
}
if len(entries) != 3 {
t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries))
}
// Purge must remove only Book B.
n, err := PurgeMissing(ctx, db, lib.ID)
if err != nil {
t.Fatalf("PurgeMissing: %v", err)
}
if n != 1 {
t.Errorf("purged = %d, want 1 (only the fully missing item)", n)
}
// Book A survives, with its present and missing files intact.
var items int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
t.Fatal(err)
}
if items != 1 {
t.Errorf("media_items after purge = %d, want 1", items)
}
var bookATitle string
if err := db.QueryRowContext(ctx,
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil {
t.Fatalf("a non-missing item was purged: %v", err)
}
var files int
if err := db.QueryRowContext(ctx, `
SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id
WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil {
t.Fatal(err)
}
if files != 2 {
t.Errorf("Book A files after purge = %d, want 2", files)
}
// The other library's missing item is untouched.
var otherItems int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil {
t.Fatal(err)
}
if otherItems != 1 {
t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems)
}
// A second purge has nothing left to remove.
if n, err := PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
t.Errorf("second purge = %d, %v; want 0, nil", n, err)
}
}

View file

@ -237,7 +237,7 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
for _, rel := range batch {
if err := markFileMissing(ctx, tx, rel); err != nil {
if err := markFileMissing(ctx, tx, rel, now); err != nil {
res.addError(fmt.Sprintf("%s: %v", rel, err))
}
}
@ -431,10 +431,10 @@ func loadKnownItems(ctx context.Context, db *sql.DB, libraryID int64) (map[strin
func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing)
VALUES (?, ?, ?, ?, ?, 0)
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, missing_since)
VALUES (?, ?, ?, ?, ?, 0, '')
ON CONFLICT (library_id, path)
DO UPDATE SET missing = 0
DO UPDATE SET missing = 0, missing_since = ''
RETURNING id`,
lib.ID, lib.Kind, key, title, strings.ToLower(title),
).Scan(&id)
@ -450,12 +450,13 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info
// upsert never mentions those columns at all.
if !probed {
_, err := tx.ExecContext(ctx, `
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing)
VALUES (?, ?, ?, ?, ?, ?, 0)
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since)
VALUES (?, ?, ?, ?, ?, ?, 0, '')
ON CONFLICT (path)
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
mtime = excluded.mtime, inode = excluded.inode,
container = excluded.container, missing = 0`,
container = excluded.container, missing = 0,
missing_since = ''`,
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
if err != nil {
return fmt.Errorf("upsert file: %w", err)
@ -464,13 +465,14 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info
}
_, err := tx.ExecContext(ctx, `
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing,
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since,
duration_ms, vcodec, acodec, width, height, probed_at)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?, ?, ?, ?, ?)
ON CONFLICT (path)
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
mtime = excluded.mtime, inode = excluded.inode,
container = excluded.container, missing = 0,
missing_since = '',
duration_ms = excluded.duration_ms, vcodec = excluded.vcodec,
acodec = excluded.acodec, width = excluded.width,
height = excluded.height, probed_at = excluded.probed_at`,
@ -535,15 +537,28 @@ func probeable(kind string) bool {
}
}
// markItemMissing flags an item whose whole group has vanished. missing_since
// records when it first disappeared, and is written only on that transition: a
// rescan of an already-missing item must not refresh the age, or a file gone
// for months would look "just now missing" on every pass. An empty timestamp
// (a row that went missing before this column existed) is filled once, so the
// age is still real rather than permanently blank.
func markItemMissing(ctx context.Context, tx *sql.Tx, libraryID int64, key, now string) error {
_, err := tx.ExecContext(ctx, `
UPDATE media_items SET missing = 1
WHERE library_id = ? AND path = ?`, libraryID, key)
UPDATE media_items SET missing = 1, missing_since = ?
WHERE library_id = ? AND path = ? AND (missing = 0 OR missing_since = '')`,
now, libraryID, key)
return err
}
func markFileMissing(ctx context.Context, tx *sql.Tx, rel string) error {
_, err := tx.ExecContext(ctx, `UPDATE media_files SET missing = 1 WHERE path = ?`, rel)
// markFileMissing flags a file that vanished while its item survived. It obeys
// the same transition rule as markItemMissing: the timestamp is set once and
// never rewritten by a later scan.
func markFileMissing(ctx context.Context, tx *sql.Tx, rel, now string) error {
_, err := tx.ExecContext(ctx, `
UPDATE media_files SET missing = 1, missing_since = ?
WHERE path = ? AND (missing = 0 OR missing_since = '')`,
now, rel)
return err
}

View file

@ -0,0 +1,118 @@
// 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")
}

View file

@ -0,0 +1,230 @@
package playback
import (
"context"
"database/sql"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/mithral/vellum/internal/auth"
"github.com/mithral/vellum/internal/core"
"github.com/mithral/vellum/internal/engine"
"github.com/mithral/vellum/migrations"
)
func testDB(t *testing.T) *sql.DB {
t.Helper()
db, err := core.OpenDB(filepath.Join(t.TempDir(), "playback.db"))
if err != nil {
t.Fatalf("OpenDB: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if err := core.Migrate(context.Background(), db, migrations.FS, log); err != nil {
t.Fatalf("Migrate: %v", err)
}
return db
}
func mustExec(t *testing.T, db *sql.DB, q string, args ...any) {
t.Helper()
if _, err := db.ExecContext(context.Background(), q, args...); err != nil {
t.Fatalf("exec %q: %v", q, err)
}
}
func mustID(t *testing.T, db *sql.DB, q string, args ...any) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(), q, args...).Scan(&id); err != nil {
t.Fatalf("query %q: %v", q, err)
}
return id
}
func newHandlers(t *testing.T, db *sql.DB) *Handlers {
t.Helper()
log := slog.New(slog.NewTextHandler(io.Discard, nil))
a := auth.NewAuthenticator(db, "vellum_session", 0, false, log)
return NewHandlers(db, log, a)
}
// fixture builds two libraries with one item each and a user granted only the
// first. The item in the second library must be invisible.
type fixture struct {
db *sql.DB
libA int64
libB int64
userA int64
itemA int64 // mp4 h264 aac, readable
itemB int64 // mp4 hevc aac, NOT readable
}
func setup(t *testing.T) fixture {
t.Helper()
db := testDB(t)
f := fixture{db: db}
f.libA = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('A','video','/a') RETURNING id`)
f.libB = mustID(t, db, `INSERT INTO libraries (name, kind, root_path) VALUES ('B','video','/b') RETURNING id`)
f.userA = mustID(t, db, `INSERT INTO users (username, password_hash) VALUES ('alice','x') RETURNING id`)
mustExec(t, db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libA)
f.itemA = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'a.mp4', 'A') RETURNING id`, f.libA)
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'a.mp4', 1, 'mp4', 'h264', 'aac')`, f.itemA)
f.itemB = mustID(t, db, `INSERT INTO media_items (library_id, kind, path, title) VALUES (?, 'video', 'b.mp4', 'B') RETURNING id`, f.libB)
mustExec(t, db, `INSERT INTO media_files (item_id, path, size, container, vcodec, acodec) VALUES (?, 'b.mp4', 1, 'mp4', 'hevc', 'aac')`, f.itemB)
return f
}
func resolve(t *testing.T, h *Handlers, u auth.User, body string) (int, engine.Resolution) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/resolve", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.handleResolve(rec, req, u)
var res engine.Resolution
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil {
t.Fatalf("decode resolution: %v (body %s)", err, rec.Body.String())
}
}
return rec.Code, res
}
func TestEnginesEndpoint(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
req := httptest.NewRequest(http.MethodGet, "/api/v1/playback/engines", nil)
rec := httptest.NewRecorder()
h.handleEngines(rec, req, auth.User{ID: f.userA})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
}
var body struct {
Engines []engine.Engine `json:"engines"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode engines: %v", err)
}
got := map[string]bool{}
for _, e := range body.Engines {
got[e.ID] = true
}
for _, id := range []string{"html5", "mpv", "vlc", "mdk"} {
if !got[id] {
t.Errorf("engines response is missing %q: %s", id, rec.Body.String())
}
}
}
func TestResolvePlayableAndNot(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
u := auth.User{ID: f.userA}
code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "html5" {
t.Fatalf("h264 resolve = %d %+v, want playable html5", code, res)
}
// The readable item's facts are h264, so to test HEVC we point at itemB but
// grant it first — scoping is tested separately.
mustExec(t, f.db, `INSERT INTO library_grants (user_id, library_id, can_read, can_download) VALUES (?, ?, 1, 1)`, f.userA, f.libB)
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
if code != http.StatusOK || res.Playable {
t.Fatalf("hevc resolve = %d %+v, want not playable", code, res)
}
if res.Reason != "vcodec:hevc" {
t.Errorf("reason = %q, want vcodec:hevc", res.Reason)
}
if !strings.Contains(res.Message, "HEVC") {
t.Errorf("message = %q, want it to name HEVC", res.Message)
}
// A native engine changes the answer.
code, res = resolve(t, h, u, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5","mpv"]}`)
if code != http.StatusOK || !res.Playable || res.Engine == nil || *res.Engine != "mpv" {
t.Fatalf("hevc+mpv resolve = %d %+v, want playable mpv", code, res)
}
}
// TestResolveGrantScoping pins the chokepoint: an item in a library the caller
// cannot read must be a 404, indistinguishable from a missing item.
func TestResolveGrantScoping(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
// userA may read libA only; itemB lives in libB.
code, _ := resolve(t, h, auth.User{ID: f.userA}, `{"item_id":`+itoa(f.itemB)+`,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("inaccessible item status = %d, want 404", code)
}
// A user with no grants gets the same 404 for a readable-by-someone item.
none := mustID(t, f.db, `INSERT INTO users (username, password_hash) VALUES ('carol','x') RETURNING id`)
code, _ = resolve(t, h, auth.User{ID: none}, `{"item_id":`+itoa(f.itemA)+`,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("no-grant item status = %d, want 404", code)
}
// A nonexistent item is also a 404, not a leak.
code, _ = resolve(t, h, auth.User{ID: f.userA}, `{"item_id":99999,"engines":["html5"]}`)
if code != http.StatusNotFound {
t.Errorf("missing item status = %d, want 404", code)
}
}
func TestResolveValidation(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
u := auth.User{ID: f.userA}
for _, body := range []string{
`{"item_id":0,"engines":["html5"]}`,
`{"item_id":-3,"engines":["html5"]}`,
} {
if code, _ := resolve(t, h, u, body); code != http.StatusBadRequest {
t.Errorf("body %s status = %d, want 400", body, code)
}
}
// Missing engines is allowed: the handler falls back to the html5 baseline.
if code, res := resolve(t, h, u, `{"item_id":`+itoa(f.itemA)+`}`); code != http.StatusOK || !res.Playable {
t.Errorf("missing engines = %d %+v, want playable fallback", code, res)
}
}
// TestRegisterRequiresAuth proves the routes are mounted behind the auth
// middleware: an unauthenticated request is rejected with 401.
func TestRegisterRequiresAuth(t *testing.T) {
f := setup(t)
h := newHandlers(t, f.db)
mux := http.NewServeMux()
h.Register(mux)
for _, tc := range []struct{ method, path, body string }{
{http.MethodGet, "/api/v1/playback/engines", ""},
{http.MethodPost, "/api/v1/playback/resolve", `{"item_id":1,"engines":["html5"]}`},
} {
req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s %s status = %d, want 401", tc.method, tc.path, rec.Code)
}
}
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }

View file

@ -0,0 +1,11 @@
-- 0011_missing_since.sql — record how long a file or item has been missing.
--
-- `missing` says a file is gone; `missing_since` says for how long. That age is
-- the whole point: it is what lets an admin tell a drive that is merely
-- unplugged from content that is genuinely deleted, and therefore whether the
-- explicit purge is safe to run. Without it every rescan would refresh the
-- timestamp and a file that has been gone for months would look "just now
-- missing" on every pass.
ALTER TABLE media_items ADD COLUMN missing_since TEXT NOT NULL DEFAULT '';
ALTER TABLE media_files ADD COLUMN missing_since TEXT NOT NULL DEFAULT '';

110
web/src/lib/capabilities.ts Normal file
View file

@ -0,0 +1,110 @@
// Client-side playback capability detection.
//
// Vellum does not transcode, so whether a file plays is a property of THIS
// client — its GPU, its decoder, its power budget — not of the file. The server
// keeps a conservative per-engine baseline (internal/engine) but cannot know the
// client's hardware. This module is how the client answers for itself.
//
// The web app's only engine is HTML5. `navigator.mediaCapabilities` is the real
// hardware test: unlike `canPlayType`, it distinguishes "cannot decode at all"
// from "decodes, but will stutter and drain the battery". It is absent in some
// browsers, so every path here is best-effort and degrades to the server's
// baseline rather than throwing — nothing in this file may ever break playback.
import type { MediaFile } from './api'
import { sourceType } from './playback'
/** What MediaCapabilities reports about one file's video configuration. */
export interface DecodingInfo {
/** The client can decode this. */
supported: boolean
/** It can decode it smoothly, at the requested rate and resolution. */
smooth: boolean
/** It can decode it without leaning on a power-hungry path. */
powerEfficient: boolean
}
/**
* A verdict with the "could not ask" case made explicit. When `unknown` is
* true the client had no way to test itself, so the caller must fall back to the
* server's baseline and keep the element as the final authority.
*/
export interface CodecVerdict extends DecodingInfo {
unknown: boolean
}
/** The engine IDs this client can offer. The web app is an HTML5 client only. */
export async function clientEngines(): Promise<string[]> {
return ['html5']
}
/** Whether this browser can be asked about decoding at all. */
export function mediaCapabilitiesAvailable(): boolean {
return (
typeof navigator !== 'undefined' &&
typeof navigator.mediaCapabilities !== 'undefined' &&
typeof navigator.mediaCapabilities.decodingInfo === 'function'
)
}
/**
* Ask the client whether it can decode one video configuration.
*
* Returns null when the API is unavailable or the query is rejected; callers
* treat null as "no opinion" and keep the server's baseline.
*/
export async function decodingInfo(config: {
contentType: string
width?: number
height?: number
bitrate?: number
framerate?: number
}): Promise<DecodingInfo | null> {
if (!config.contentType || !mediaCapabilitiesAvailable()) return null
try {
// The dimensions/bitrate/framerate are what let MediaCapabilities judge
// smoothness and power, not just support, so sensible defaults are supplied
// when the file facts are incomplete.
const info = await navigator.mediaCapabilities.decodingInfo({
type: 'file',
video: {
contentType: config.contentType,
width: config.width || 1920,
height: config.height || 1080,
bitrate: config.bitrate || 8_000_000,
framerate: config.framerate || 30,
},
})
return {
supported: info.supported,
smooth: info.smooth,
powerEfficient: info.powerEfficient,
}
} catch {
// A malformed codec string, an unsupported query shape, a browser quirk:
// none of it should stop the file from being tried.
return null
}
}
/**
* Test one file against this client's real decoder.
*
* The codec string comes from the shared mapping in playback.ts, so the same
* RFC 6381 parameters are used for the element's `type` hint and this probe.
* Returns an explicit `unknown` verdict when there is nothing to ask or the
* container is unrecognised.
*/
export async function probeFile(file: MediaFile): Promise<CodecVerdict> {
const unknown: CodecVerdict = { supported: true, smooth: true, powerEfficient: true, unknown: true }
const contentType = sourceType(file)
if (!contentType) return unknown
const info = await decodingInfo({
contentType,
width: file.width,
height: file.height,
})
if (!info) return unknown
return { ...info, unknown: false }
}

View file

@ -0,0 +1,231 @@
<script lang="ts">
import { onMount } from 'svelte'
import { api, ApiError, type MissingEntry } from '../api'
import { libraries } from '../session'
let entries = $state<MissingEntry[]>([])
let loading = $state(true)
let busy = $state<number | null>(null)
let error = $state<string | null>(null)
let notice = $state<string | null>(null)
onMount(load)
async function load() {
loading = true
error = null
try {
const res = await api.missing()
entries = res.items ?? []
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not load missing files'
} finally {
loading = false
}
}
// Group by library so each group can offer its own purge. Purging deletes
// items, not files, so the count an admin confirms is distinct item ids — a
// multi-track audiobook is one item even though it contributes several files.
const groups = $derived.by(() => {
const byLib = new Map<number, MissingEntry[]>()
for (const e of entries) {
const list = byLib.get(e.library_id)
if (list) list.push(e)
else byLib.set(e.library_id, [e])
}
return [...byLib.entries()]
.map(([libraryId, items]) => ({
libraryId,
name: $libraries.find((l) => l.id === libraryId)?.name ?? `Library ${libraryId}`,
items,
itemCount: new Set(items.map((i) => i.item_id)).size,
}))
.sort((a, b) => a.name.localeCompare(b.name))
})
const totalFiles = $derived(entries.length)
const totalItems = $derived(new Set(entries.map((e) => e.item_id)).size)
async function purge(libraryId: number, name: string, itemCount: number) {
const noun = itemCount === 1 ? 'item' : 'items'
const ok = confirm(
`Purge ${itemCount} missing ${noun} from “${name}”?\n\n` +
'This permanently deletes each item, its files, and any watch or read ' +
'progress recorded for them. Progress cannot be regenerated. This ' +
'cannot be undone.',
)
if (!ok) return
busy = libraryId
error = null
notice = null
try {
const res = await api.purgeMissing(libraryId)
notice = `Purged ${res.purged} ${res.purged === 1 ? 'item' : 'items'} from “${name}”.`
await load()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not purge missing files'
} finally {
busy = null
}
}
// Human age from an RFC3339 timestamp, e.g. "3 days". The age is the point:
// a drive unplugged this morning is not the same as content gone for months.
function age(since: string): string {
if (!since) return 'unknown'
const then = Date.parse(since)
if (Number.isNaN(then)) return 'unknown'
let value = Math.max(0, Math.floor((Date.now() - then) / 1000))
const steps: [number, string][] = [
[60, 'second'],
[60, 'minute'],
[24, 'hour'],
[7, 'day'],
[4, 'week'],
[12, 'month'],
[Infinity, 'year'],
]
let label = 'second'
for (const [size, name] of steps) {
if (value < size) {
label = name
break
}
value = Math.floor(value / size)
label = name
}
return `${value} ${label}${value === 1 ? '' : 's'}`
}
</script>
{#if error}<div class="alert">{error}</div>{/if}
{#if notice}<div class="ok">{notice}</div>{/if}
{#if loading}
<div class="empty"><span class="spinner"></span><p>Looking for missing files…</p></div>
{:else if entries.length === 0}
<div class="empty">
<h3>Nothing is missing</h3>
<p>Every file the scanner knows about is still on disk. Files that vanish are kept here until you purge them.</p>
</div>
{:else}
<p class="summary muted">
{totalFiles} missing {totalFiles === 1 ? 'file' : 'files'} across
{totalItems} {totalItems === 1 ? 'item' : 'items'}. A rescan never deletes; purge is the only way to remove them.
</p>
{#each groups as group (group.libraryId)}
<section class="card group">
<header class="head">
<div class="titles">
<strong>{group.name}</strong>
<span class="muted">{group.itemCount} {group.itemCount === 1 ? 'item' : 'items'} · {group.items.length} {group.items.length === 1 ? 'file' : 'files'}</span>
</div>
<button
class="btn btn-ghost danger"
disabled={busy !== null}
onclick={() => purge(group.libraryId, group.name, group.itemCount)}
>
{busy === group.libraryId ? 'Purging…' : 'Purge'}
</button>
</header>
<ul class="files">
{#each group.items as entry (entry.file_id)}
<li class="file">
<div class="what">
<span class="title">{entry.title || entry.path}</span>
<span class="path mono">{entry.path}</span>
</div>
<span class="badge kind">{entry.kind}</span>
<span class="since muted" title={entry.missing_since || 'unknown'}>missing {age(entry.missing_since)}</span>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
<style>
.summary {
margin: 0 0 var(--space-4);
font-size: var(--fs-md);
}
.group {
margin-bottom: var(--space-4);
overflow: hidden;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
}
.titles {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.titles .muted {
font-size: var(--fs-sm);
}
.danger {
color: var(--danger);
}
.files {
list-style: none;
margin: 0;
padding: 0;
}
.file {
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
}
.file + .file {
border-top: 1px solid var(--border);
}
.what {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.title {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.path {
color: var(--faint);
word-break: break-all;
}
.kind {
text-transform: uppercase;
letter-spacing: 0.05em;
}
.since {
font-size: var(--fs-sm);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.empty .spinner {
margin-bottom: var(--space-2);
}
@media (max-width: 640px) {
.file {
grid-template-columns: 1fr auto;
}
.since {
grid-column: 1 / -1;
}
}
</style>

View file

@ -10,8 +10,13 @@
import SearchResults from './SearchResults.svelte'
import Libraries from './Libraries.svelte'
import Users from './Users.svelte'
import MissingFiles from './MissingFiles.svelte'
import Settings from './Settings.svelte'
// The admin "Missing files" view is shell-local, so the shell widens the
// shared View union here rather than forking nav.ts for one admin screen.
type ShellView = View | { name: 'missing' }
let searchBox = $state('')
let scrolled = $state(false)
let contentEl = $state<HTMLDivElement>()
@ -19,6 +24,7 @@
// A stable key for the current view, so navigating transitions the content
// instead of swapping it instantly.
const viewKey = $derived(JSON.stringify($view))
const current = $derived($view as ShellView)
function onScroll() {
scrolled = (contentEl?.scrollTop ?? 0) > 8
@ -43,7 +49,7 @@
if (q) go({ name: 'search', query: q })
}
function title(v: View): string {
function title(v: ShellView): string {
switch (v.name) {
case 'home':
return 'Home'
@ -60,6 +66,8 @@
return 'Libraries'
case 'users':
return 'Users'
case 'missing':
return 'Missing files'
case 'settings':
return 'Appearance'
}
@ -104,6 +112,13 @@
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
<span>Users</span>
</button>
<button
class="navlink"
class:active={current.name === 'missing'}
onclick={() => go({ name: 'missing' } as unknown as View)}
>
<span>Missing files</span>
</button>
</nav>
{/if}
@ -157,6 +172,8 @@
<Libraries />
{:else if $view.name === 'users'}
<Users />
{:else if current.name === 'missing'}
<MissingFiles />
{:else if $view.name === 'settings'}
<Settings />
{/if}

View file

@ -2,6 +2,7 @@
import { onMount, onDestroy } from 'svelte'
import { api, ApiError, type Item, type MediaFile } from '../api'
import * as playback from '../playback'
import { clientEngines, probeFile } from '../capabilities'
import {
setNowPlaying,
setHandlers,
@ -49,6 +50,15 @@
let undecodable = $state(false)
let videoDecoded = $state(false)
// `serverNote` is the server's advisory verdict for the item's first file
// (api.resolvePlayback). It SUPPLEMENTS the element watchdog, never replaces
// it: it is shown only until the element reports a real picture, and the
// element's own verdict always wins once it has one.
let serverNote = $state('')
// `qualityNote` is this client's MediaCapabilities refinement — a codec it can
// decode, but not smoothly. Advisory only; it never blocks playback.
let qualityNote = $state('')
let saveTimer: number | undefined
let verifyTimer: number | undefined
let loadWatchdog: number | undefined
@ -59,6 +69,12 @@
const file = $derived(files[index] ?? null)
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
const srcType = $derived(file ? playback.sourceType(file) : '')
// The server resolves an ITEM using its first file's codec facts, so its
// advisory applies only while that first file is selected. Once the element
// has decoded a picture the server note is hidden regardless: the element is
// the source of truth.
const serverApplies = $derived(files.length > 0 && index === 0)
const displayedNotice = $derived(notice || (serverApplies && !videoDecoded ? serverNote : ''))
// `type` belongs on a <source>, but putting it there lets the browser SKIP a
// file whose canPlayType answer is wrong — it would refuse to try mov+h264
// (which plays) and would never even parse HEVC, so `videoWidth` stays 0 and
@ -119,6 +135,10 @@
}
publishNowPlaying()
registerHandlers()
// Ask the server (and this client's own decoder) what they make of the
// file. Best-effort: the element watchdog below is still the authority,
// so this never blocks the player and a failure is never shown as an error.
void refinePlayback()
} catch (err) {
error = err instanceof ApiError ? err.message : 'could not open this video'
} finally {
@ -194,6 +214,42 @@
return 'This browser cannot decode the video track in this file, so you would only hear audio over a blank picture. Vellum does not transcode.'
}
// Ask the server which of this client's engines can play the item, then let
// the client's own decoder sharpen the answer. Both results are ADVISORY: the
// loaded element is still the source of truth (see checkDecoded / onError),
// so a server or MediaCapabilities failure must never surface as an error and
// must never stop the file from being tried.
async function refinePlayback() {
try {
const engines = await clientEngines()
const res = await api.resolvePlayback(itemId, engines)
if (!res.playable && res.message) serverNote = res.message
} catch {
// Endpoint absent (older server) or unreachable: the element watchdog
// still covers the honest path.
}
const f = files[index] ?? files[0]
if (!f) return
try {
const verdict = await probeFile(f)
if (verdict.unknown) return
if (!verdict.supported) {
// The client's own hardware check disagrees with the server baseline:
// report it rather than promising a picture that will not come.
if (!serverNote) {
serverNote =
'This browser reports it cannot decode this file’s video, so you would only hear audio over a blank picture.'
}
} else if (!verdict.smooth) {
// Supported, but the client says it will stutter or drain the battery.
qualityNote = 'This browser can decode this file, but its own hardware check says it may not play smoothly.'
}
} catch {
// Best-effort refinement only.
}
}
function showUndecodable(f: MediaFile | null, fallback = '') {
notice = honestNotice(f) || fallback
undecodable = true
@ -484,10 +540,10 @@
<a class="btn btn-ghost dl" href={api.fileUrl(itemId)} download>Download</a>
</div>
{#if notice}
{#if displayedNotice}
<div class="notice" role="alert">
<strong>Video cannot play here</strong>
<p>{notice}</p>
<strong>{undecodable ? 'Video cannot play here' : 'Playback may not work here'}</strong>
<p>{displayedNotice}</p>
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
</div>
{/if}
@ -520,6 +576,10 @@
<p class="ok-note" aria-live="polite">Playing video directly — no transcoding.</p>
{/if}
{#if qualityNote && !undecodable}
<p class="quality-note muted" aria-live="polite">{qualityNote}</p>
{/if}
<input
class="seek"
type="range"
@ -641,6 +701,10 @@
font-size: var(--fs-sm);
color: var(--success);
}
.quality-note {
margin: 0;
font-size: var(--fs-sm);
}
.seek {
width: 100%;
accent-color: var(--accent);