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:
parent
915f390156
commit
67f145a9b1
15 changed files with 1774 additions and 17 deletions
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"github.com/mithral/vellum/internal/modules/ebooks"
|
"github.com/mithral/vellum/internal/modules/ebooks"
|
||||||
"github.com/mithral/vellum/internal/modules/games"
|
"github.com/mithral/vellum/internal/modules/games"
|
||||||
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
|
_ "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/internal/search"
|
||||||
"github.com/mithral/vellum/migrations"
|
"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())
|
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
games.NewHandlers(db, log, authenticator).Register(api.Mux())
|
games.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||||
search.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
|
// Scans run on the queue rather than inside the request: a real library
|
||||||
// takes longer than any sensible timeout, and a hanging request is
|
// takes longer than any sensible timeout, and a hanging request is
|
||||||
|
|
|
||||||
203
internal/engine/engine.go
Normal file
203
internal/engine/engine.go
Normal 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
|
||||||
|
}
|
||||||
245
internal/engine/engine_test.go
Normal file
245
internal/engine/engine_test.go
Normal 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
156
internal/engine/resolve.go
Normal 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 ""
|
||||||
|
}
|
||||||
|
|
@ -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}", 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}/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/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("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("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}/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.
|
// 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})
|
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 ────────────────────────────────────────────────────────────
|
// ── 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) {
|
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
||||||
h.log.Error(what, "err", err)
|
h.log.Error(what, "err", err)
|
||||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,107 @@ func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) {
|
||||||
return n, err
|
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 ────────────────────────────────────────────────────────────
|
// ── helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func resolveRoot(rootPath string) (string, error) {
|
func resolveRoot(rootPath string) (string, error) {
|
||||||
|
|
|
||||||
|
|
@ -109,3 +109,194 @@ func TestListVisibleNeverMeansAll(t *testing.T) {
|
||||||
t.Errorf("ListVisible(nil) returned %d libraries; empty set must mean none", len(libs))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
||||||
for _, rel := range batch {
|
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))
|
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) {
|
func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string) (int64, error) {
|
||||||
var id int64
|
var id int64
|
||||||
err := tx.QueryRowContext(ctx, `
|
err := tx.QueryRowContext(ctx, `
|
||||||
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing)
|
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, missing_since)
|
||||||
VALUES (?, ?, ?, ?, ?, 0)
|
VALUES (?, ?, ?, ?, ?, 0, '')
|
||||||
ON CONFLICT (library_id, path)
|
ON CONFLICT (library_id, path)
|
||||||
DO UPDATE SET missing = 0
|
DO UPDATE SET missing = 0, missing_since = ''
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
lib.ID, lib.Kind, key, title, strings.ToLower(title),
|
lib.ID, lib.Kind, key, title, strings.ToLower(title),
|
||||||
).Scan(&id)
|
).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.
|
// upsert never mentions those columns at all.
|
||||||
if !probed {
|
if !probed {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, 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)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
VALUES (?, ?, ?, ?, ?, ?, 0, '')
|
||||||
ON CONFLICT (path)
|
ON CONFLICT (path)
|
||||||
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
||||||
mtime = excluded.mtime, inode = excluded.inode,
|
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)
|
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("upsert file: %w", err)
|
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, `
|
_, 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)
|
duration_ms, vcodec, acodec, width, height, probed_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT (path)
|
ON CONFLICT (path)
|
||||||
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
||||||
mtime = excluded.mtime, inode = excluded.inode,
|
mtime = excluded.mtime, inode = excluded.inode,
|
||||||
container = excluded.container, missing = 0,
|
container = excluded.container, missing = 0,
|
||||||
|
missing_since = '',
|
||||||
duration_ms = excluded.duration_ms, vcodec = excluded.vcodec,
|
duration_ms = excluded.duration_ms, vcodec = excluded.vcodec,
|
||||||
acodec = excluded.acodec, width = excluded.width,
|
acodec = excluded.acodec, width = excluded.width,
|
||||||
height = excluded.height, probed_at = excluded.probed_at`,
|
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 {
|
func markItemMissing(ctx context.Context, tx *sql.Tx, libraryID int64, key, now string) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
UPDATE media_items SET missing = 1
|
UPDATE media_items SET missing = 1, missing_since = ?
|
||||||
WHERE library_id = ? AND path = ?`, libraryID, key)
|
WHERE library_id = ? AND path = ? AND (missing = 0 OR missing_since = '')`,
|
||||||
|
now, libraryID, key)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func markFileMissing(ctx context.Context, tx *sql.Tx, rel string) error {
|
// markFileMissing flags a file that vanished while its item survived. It obeys
|
||||||
_, err := tx.ExecContext(ctx, `UPDATE media_files SET missing = 1 WHERE path = ?`, rel)
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
118
internal/playback/handlers.go
Normal file
118
internal/playback/handlers.go
Normal 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")
|
||||||
|
}
|
||||||
230
internal/playback/handlers_test.go
Normal file
230
internal/playback/handlers_test.go
Normal 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) }
|
||||||
11
migrations/0011_missing_since.sql
Normal file
11
migrations/0011_missing_since.sql
Normal 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
110
web/src/lib/capabilities.ts
Normal 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 }
|
||||||
|
}
|
||||||
231
web/src/lib/components/MissingFiles.svelte
Normal file
231
web/src/lib/components/MissingFiles.svelte
Normal 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>
|
||||||
|
|
@ -10,8 +10,13 @@
|
||||||
import SearchResults from './SearchResults.svelte'
|
import SearchResults from './SearchResults.svelte'
|
||||||
import Libraries from './Libraries.svelte'
|
import Libraries from './Libraries.svelte'
|
||||||
import Users from './Users.svelte'
|
import Users from './Users.svelte'
|
||||||
|
import MissingFiles from './MissingFiles.svelte'
|
||||||
import Settings from './Settings.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 searchBox = $state('')
|
||||||
let scrolled = $state(false)
|
let scrolled = $state(false)
|
||||||
let contentEl = $state<HTMLDivElement>()
|
let contentEl = $state<HTMLDivElement>()
|
||||||
|
|
@ -19,6 +24,7 @@
|
||||||
// A stable key for the current view, so navigating transitions the content
|
// A stable key for the current view, so navigating transitions the content
|
||||||
// instead of swapping it instantly.
|
// instead of swapping it instantly.
|
||||||
const viewKey = $derived(JSON.stringify($view))
|
const viewKey = $derived(JSON.stringify($view))
|
||||||
|
const current = $derived($view as ShellView)
|
||||||
|
|
||||||
function onScroll() {
|
function onScroll() {
|
||||||
scrolled = (contentEl?.scrollTop ?? 0) > 8
|
scrolled = (contentEl?.scrollTop ?? 0) > 8
|
||||||
|
|
@ -43,7 +49,7 @@
|
||||||
if (q) go({ name: 'search', query: q })
|
if (q) go({ name: 'search', query: q })
|
||||||
}
|
}
|
||||||
|
|
||||||
function title(v: View): string {
|
function title(v: ShellView): string {
|
||||||
switch (v.name) {
|
switch (v.name) {
|
||||||
case 'home':
|
case 'home':
|
||||||
return 'Home'
|
return 'Home'
|
||||||
|
|
@ -60,6 +66,8 @@
|
||||||
return 'Libraries'
|
return 'Libraries'
|
||||||
case 'users':
|
case 'users':
|
||||||
return 'Users'
|
return 'Users'
|
||||||
|
case 'missing':
|
||||||
|
return 'Missing files'
|
||||||
case 'settings':
|
case 'settings':
|
||||||
return 'Appearance'
|
return 'Appearance'
|
||||||
}
|
}
|
||||||
|
|
@ -104,6 +112,13 @@
|
||||||
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
|
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
|
||||||
<span>Users</span>
|
<span>Users</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="navlink"
|
||||||
|
class:active={current.name === 'missing'}
|
||||||
|
onclick={() => go({ name: 'missing' } as unknown as View)}
|
||||||
|
>
|
||||||
|
<span>Missing files</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -157,6 +172,8 @@
|
||||||
<Libraries />
|
<Libraries />
|
||||||
{:else if $view.name === 'users'}
|
{:else if $view.name === 'users'}
|
||||||
<Users />
|
<Users />
|
||||||
|
{:else if current.name === 'missing'}
|
||||||
|
<MissingFiles />
|
||||||
{:else if $view.name === 'settings'}
|
{:else if $view.name === 'settings'}
|
||||||
<Settings />
|
<Settings />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { api, ApiError, type Item, type MediaFile } from '../api'
|
import { api, ApiError, type Item, type MediaFile } from '../api'
|
||||||
import * as playback from '../playback'
|
import * as playback from '../playback'
|
||||||
|
import { clientEngines, probeFile } from '../capabilities'
|
||||||
import {
|
import {
|
||||||
setNowPlaying,
|
setNowPlaying,
|
||||||
setHandlers,
|
setHandlers,
|
||||||
|
|
@ -49,6 +50,15 @@
|
||||||
let undecodable = $state(false)
|
let undecodable = $state(false)
|
||||||
let videoDecoded = $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 saveTimer: number | undefined
|
||||||
let verifyTimer: number | undefined
|
let verifyTimer: number | undefined
|
||||||
let loadWatchdog: number | undefined
|
let loadWatchdog: number | undefined
|
||||||
|
|
@ -59,6 +69,12 @@
|
||||||
const file = $derived(files[index] ?? null)
|
const file = $derived(files[index] ?? null)
|
||||||
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
|
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
|
||||||
const srcType = $derived(file ? playback.sourceType(file) : '')
|
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
|
// `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
|
// 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
|
// (which plays) and would never even parse HEVC, so `videoWidth` stays 0 and
|
||||||
|
|
@ -119,6 +135,10 @@
|
||||||
}
|
}
|
||||||
publishNowPlaying()
|
publishNowPlaying()
|
||||||
registerHandlers()
|
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) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'could not open this video'
|
error = err instanceof ApiError ? err.message : 'could not open this video'
|
||||||
} finally {
|
} 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.'
|
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 = '') {
|
function showUndecodable(f: MediaFile | null, fallback = '') {
|
||||||
notice = honestNotice(f) || fallback
|
notice = honestNotice(f) || fallback
|
||||||
undecodable = true
|
undecodable = true
|
||||||
|
|
@ -484,10 +540,10 @@
|
||||||
<a class="btn btn-ghost dl" href={api.fileUrl(itemId)} download>Download</a>
|
<a class="btn btn-ghost dl" href={api.fileUrl(itemId)} download>Download</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if notice}
|
{#if displayedNotice}
|
||||||
<div class="notice" role="alert">
|
<div class="notice" role="alert">
|
||||||
<strong>Video cannot play here</strong>
|
<strong>{undecodable ? 'Video cannot play here' : 'Playback may not work here'}</strong>
|
||||||
<p>{notice}</p>
|
<p>{displayedNotice}</p>
|
||||||
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
|
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -520,6 +576,10 @@
|
||||||
<p class="ok-note" aria-live="polite">Playing video directly — no transcoding.</p>
|
<p class="ok-note" aria-live="polite">Playing video directly — no transcoding.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if qualityNote && !undecodable}
|
||||||
|
<p class="quality-note muted" aria-live="polite">{qualityNote}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
class="seek"
|
class="seek"
|
||||||
type="range"
|
type="range"
|
||||||
|
|
@ -641,6 +701,10 @@
|
||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
.quality-note {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
}
|
||||||
.seek {
|
.seek {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: var(--accent);
|
accent-color: var(--accent);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue