From 67f145a9b1578388741156622bd54a5f1d09329c Mon Sep 17 00:00:00 2001 From: mithral Date: Sun, 11 Oct 2026 14:36:55 -0400 Subject: [PATCH] Waves 7-8: playback engine selection, and missing files you can see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/vellum/main.go | 5 + internal/engine/engine.go | 203 +++++++++++++++++ internal/engine/engine_test.go | 245 +++++++++++++++++++++ internal/engine/resolve.go | 156 +++++++++++++ internal/library/handlers.go | 60 +++++ internal/library/library.go | 101 +++++++++ internal/library/library_test.go | 191 ++++++++++++++++ internal/library/scan.go | 41 ++-- internal/playback/handlers.go | 118 ++++++++++ internal/playback/handlers_test.go | 230 +++++++++++++++++++ migrations/0011_missing_since.sql | 11 + web/src/lib/capabilities.ts | 110 +++++++++ web/src/lib/components/MissingFiles.svelte | 231 +++++++++++++++++++ web/src/lib/components/Shell.svelte | 19 +- web/src/lib/components/VideoPlayer.svelte | 70 +++++- 15 files changed, 1774 insertions(+), 17 deletions(-) create mode 100644 internal/engine/engine.go create mode 100644 internal/engine/engine_test.go create mode 100644 internal/engine/resolve.go create mode 100644 internal/playback/handlers.go create mode 100644 internal/playback/handlers_test.go create mode 100644 migrations/0011_missing_since.sql create mode 100644 web/src/lib/capabilities.ts create mode 100644 web/src/lib/components/MissingFiles.svelte diff --git a/cmd/vellum/main.go b/cmd/vellum/main.go index 0daf9d5..89aef3f 100644 --- a/cmd/vellum/main.go +++ b/cmd/vellum/main.go @@ -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 diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..204b982 --- /dev/null +++ b/internal/engine/engine.go @@ -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 +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..8fa91dc --- /dev/null +++ b/internal/engine/engine_test.go @@ -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) + } +} diff --git a/internal/engine/resolve.go b/internal/engine/resolve.go new file mode 100644 index 0000000..2a5f1f6 --- /dev/null +++ b/internal/engine/resolve.go @@ -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 "" +} diff --git a/internal/library/handlers.go b/internal/library/handlers.go index 7e70b8e..4a32c0b 100644 --- a/internal/library/handlers.go +++ b/internal/library/handlers.go @@ -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") diff --git a/internal/library/library.go b/internal/library/library.go index 298268c..2ef06ad 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -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) { diff --git a/internal/library/library_test.go b/internal/library/library_test.go index 2ab7f99..8754f49 100644 --- a/internal/library/library_test.go +++ b/internal/library/library_test.go @@ -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) + } +} diff --git a/internal/library/scan.go b/internal/library/scan.go index fb4e5c8..7331e71 100644 --- a/internal/library/scan.go +++ b/internal/library/scan.go @@ -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 } diff --git a/internal/playback/handlers.go b/internal/playback/handlers.go new file mode 100644 index 0000000..8e62f20 --- /dev/null +++ b/internal/playback/handlers.go @@ -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") +} diff --git a/internal/playback/handlers_test.go b/internal/playback/handlers_test.go new file mode 100644 index 0000000..640090a --- /dev/null +++ b/internal/playback/handlers_test.go @@ -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) } diff --git a/migrations/0011_missing_since.sql b/migrations/0011_missing_since.sql new file mode 100644 index 0000000..943f537 --- /dev/null +++ b/migrations/0011_missing_since.sql @@ -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 ''; diff --git a/web/src/lib/capabilities.ts b/web/src/lib/capabilities.ts new file mode 100644 index 0000000..44af920 --- /dev/null +++ b/web/src/lib/capabilities.ts @@ -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 { + 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 { + 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 { + 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 } +} diff --git a/web/src/lib/components/MissingFiles.svelte b/web/src/lib/components/MissingFiles.svelte new file mode 100644 index 0000000..a9eee9b --- /dev/null +++ b/web/src/lib/components/MissingFiles.svelte @@ -0,0 +1,231 @@ + + +{#if error}
{error}
{/if} +{#if notice}
{notice}
{/if} + +{#if loading} +

Looking for missing files…

+{:else if entries.length === 0} +
+

Nothing is missing

+

Every file the scanner knows about is still on disk. Files that vanish are kept here until you purge them.

+
+{:else} +

+ {totalFiles} missing {totalFiles === 1 ? 'file' : 'files'} across + {totalItems} {totalItems === 1 ? 'item' : 'items'}. A rescan never deletes; purge is the only way to remove them. +

+ + {#each groups as group (group.libraryId)} +
+
+
+ {group.name} + {group.itemCount} {group.itemCount === 1 ? 'item' : 'items'} · {group.items.length} {group.items.length === 1 ? 'file' : 'files'} +
+ +
+ +
    + {#each group.items as entry (entry.file_id)} +
  • +
    + {entry.title || entry.path} + {entry.path} +
    + {entry.kind} + missing {age(entry.missing_since)} +
  • + {/each} +
+
+ {/each} +{/if} + + diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index 628da54..f073d6f 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -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() @@ -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 @@ + {/if} @@ -157,6 +172,8 @@ {:else if $view.name === 'users'} + {:else if current.name === 'missing'} + {:else if $view.name === 'settings'} {/if} diff --git a/web/src/lib/components/VideoPlayer.svelte b/web/src/lib/components/VideoPlayer.svelte index 579da4f..01df8ec 100644 --- a/web/src/lib/components/VideoPlayer.svelte +++ b/web/src/lib/components/VideoPlayer.svelte @@ -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 , 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 @@ Download - {#if notice} + {#if displayedNotice} {/if} @@ -520,6 +576,10 @@

Playing video directly — no transcoding.

{/if} + {#if qualityNote && !undecodable} +

{qualityNote}

+ {/if} +