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/games"
|
||||
_ "github.com/mithral/vellum/internal/modules/photos" // registers the thumbnail extractor
|
||||
"github.com/mithral/vellum/internal/playback"
|
||||
"github.com/mithral/vellum/internal/search"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
|
@ -104,6 +105,10 @@ func run(cfg core.Config, log *slog.Logger) error {
|
|||
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
games.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
search.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
// Playback engine registry + selection. Vellum never transcodes, so the
|
||||
// server reports what is inside a file and matches it against the engines the
|
||||
// CLIENT declares — a browser offers "html5", a native client can offer mpv.
|
||||
playback.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
|
||||
// Scans run on the queue rather than inside the request: a real library
|
||||
// takes longer than any sensible timeout, and a hanging request is
|
||||
|
|
|
|||
203
internal/engine/engine.go
Normal file
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}/items", h.auth.Require(h.handleListItems))
|
||||
mux.Handle("GET /api/v1/libraries/{id}/scan/status", h.auth.RequireAuth(http.HandlerFunc(h.handleScanStatus)))
|
||||
mux.Handle("GET /api/v1/missing", h.auth.Require(h.handleMissing))
|
||||
|
||||
mux.Handle("POST /api/v1/libraries", h.auth.RequireAdmin(http.HandlerFunc(h.handleCreate)))
|
||||
mux.Handle("DELETE /api/v1/libraries/{id}", h.auth.RequireAdmin(http.HandlerFunc(h.handleDelete)))
|
||||
mux.Handle("POST /api/v1/libraries/{id}/scan", h.auth.RequireAdmin(http.HandlerFunc(h.handleScan)))
|
||||
mux.Handle("POST /api/v1/libraries/{id}/purge", h.auth.RequireAdmin(http.HandlerFunc(h.handlePurge)))
|
||||
}
|
||||
|
||||
// handleMediaTypes is public: the UI needs the list before login to render.
|
||||
|
|
@ -262,8 +264,66 @@ func (h *Handlers) handleListItems(w http.ResponseWriter, r *http.Request, u aut
|
|||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": items, "limit": limit})
|
||||
}
|
||||
|
||||
// handleMissing lists the files the scanner has flagged missing in the
|
||||
// libraries the caller may read. An optional ?library_id= narrows the result
|
||||
// to a single library, and an id outside the caller's visible set simply
|
||||
// yields nothing rather than leaking that the library exists.
|
||||
func (h *Handlers) handleMissing(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
ids, err := auth.VisibleLibraryIDs(r.Context(), h.db, u)
|
||||
if err != nil {
|
||||
h.fail(w, "visible libraries", err)
|
||||
return
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("library_id"); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid library_id")
|
||||
return
|
||||
}
|
||||
ids = narrowToLibrary(ids, id)
|
||||
}
|
||||
|
||||
entries, err := ListMissing(r.Context(), h.db, ids)
|
||||
if err != nil {
|
||||
h.fail(w, "list missing", err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": entries, "total": len(entries)})
|
||||
}
|
||||
|
||||
// handlePurge deletes a library's genuinely missing items on explicit admin
|
||||
// request. It is admin-only and never runs automatically: the delete cascades
|
||||
// into progress, which cannot be regenerated. See PurgeMissing for the full
|
||||
// cascade and the exact "actually missing" condition.
|
||||
func (h *Handlers) handlePurge(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := pathInt(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := PurgeMissing(r.Context(), h.db, id)
|
||||
if err != nil {
|
||||
h.fail(w, "purge missing", err)
|
||||
return
|
||||
}
|
||||
h.log.Info("purged missing items", "library", id, "count", n)
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"purged": n})
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// narrowToLibrary keeps only id when the caller asks for one library. An id
|
||||
// outside the visible set yields an empty set, so a non-admin cannot probe a
|
||||
// library they cannot read.
|
||||
func narrowToLibrary(ids []int64, id int64) []int64 {
|
||||
for _, v := range ids {
|
||||
if v == id {
|
||||
return []int64{id}
|
||||
}
|
||||
}
|
||||
return []int64{}
|
||||
}
|
||||
|
||||
func (h *Handlers) fail(w http.ResponseWriter, what string, err error) {
|
||||
h.log.Error(what, "err", err)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||
|
|
|
|||
|
|
@ -118,6 +118,107 @@ func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) {
|
|||
return n, err
|
||||
}
|
||||
|
||||
// MissingEntry is one missing file, surfaced so an admin can see what the
|
||||
// scanner has flagged and decide whether to purge it.
|
||||
//
|
||||
// It carries both the file (file_id) and its owning item (item_id) because the
|
||||
// file is what actually vanished, while the item is what a purge deletes. A
|
||||
// multi-file item (an audiobook of tracks) produces one entry per missing file.
|
||||
type MissingEntry struct {
|
||||
ItemID int64 `json:"item_id"`
|
||||
FileID int64 `json:"file_id"`
|
||||
LibraryID int64 `json:"library_id"`
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Path string `json:"path"`
|
||||
MissingSince string `json:"missing_since"`
|
||||
}
|
||||
|
||||
// ListMissing returns the missing files in the given libraries, grouped by
|
||||
// library and then by title.
|
||||
//
|
||||
// It is keyed on media_files because a missing file is the thing the scanner
|
||||
// saw disappear; an item whose whole group is gone has each of its files
|
||||
// flagged, so every missing item still appears. ids is the caller's visible
|
||||
// set, resolved by the auth layer — an empty set yields nothing, never "all",
|
||||
// which is the same rule ListVisible follows.
|
||||
func ListMissing(ctx context.Context, db *sql.DB, ids []int64) ([]MissingEntry, error) {
|
||||
if len(ids) == 0 {
|
||||
return []MissingEntry{}, nil
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT i.id, f.id, i.library_id, i.kind, i.title, f.path,
|
||||
CASE WHEN f.missing_since <> '' THEN f.missing_since ELSE i.missing_since END
|
||||
FROM media_files f
|
||||
JOIN media_items i ON i.id = f.item_id
|
||||
WHERE f.missing = 1 AND i.library_id IN (`+strings.Join(placeholders, ",")+`)
|
||||
ORDER BY i.library_id, i.sort_title, f.path`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("library: list missing: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []MissingEntry{}
|
||||
for rows.Next() {
|
||||
var e MissingEntry
|
||||
if err := rows.Scan(&e.ItemID, &e.FileID, &e.LibraryID, &e.Kind, &e.Title, &e.Path, &e.MissingSince); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PurgeMissing deletes the items in a library that are genuinely missing:
|
||||
// flagged missing and with no file left present. It returns how many items were
|
||||
// removed.
|
||||
//
|
||||
// This is the ONLY place media rows are deleted on purpose, and it is an
|
||||
// explicit admin action rather than part of a scan because the delete is a
|
||||
// one-way door. Deleting a media_items row cascades (foreign_keys is on) into:
|
||||
//
|
||||
// - media_files.item_id — the files themselves
|
||||
// - progress.item_id — watch/read positions, which cannot be
|
||||
// regenerated; this is why a scan never deletes
|
||||
// - progress.file_id — via the cascaded media_files rows
|
||||
// - item_people.item_id — cast and author credits
|
||||
// - provider_ids.item_id — external metadata matches
|
||||
// - metadata_fetch.item_id — provider attempt history
|
||||
// - assets.item_id — covers, thumbnails, extracted pages
|
||||
// - collection_items.item_id — collection membership
|
||||
// - favorites.item_id
|
||||
// - item_views.item_id — per-user view counts
|
||||
// - game_saves.item_id — emulator save states
|
||||
// - child media_items — the parent_id self-reference
|
||||
//
|
||||
// The WHERE clause is deliberately narrow: an item is purged only when it is
|
||||
// flagged missing AND has no present file, so an item missing one track while
|
||||
// its other tracks remain on disk is never touched.
|
||||
func PurgeMissing(ctx context.Context, db *sql.DB, libraryID int64) (int64, error) {
|
||||
res, err := db.ExecContext(ctx, `
|
||||
DELETE FROM media_items
|
||||
WHERE library_id = ?
|
||||
AND missing = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM media_files f
|
||||
WHERE f.item_id = media_items.id AND f.missing = 0
|
||||
)`, libraryID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("library: purge missing: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("library: purge missing: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func resolveRoot(rootPath string) (string, error) {
|
||||
|
|
|
|||
|
|
@ -109,3 +109,194 @@ func TestListVisibleNeverMeansAll(t *testing.T) {
|
|||
t.Errorf("ListVisible(nil) returned %d libraries; empty set must mean none", len(libs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSinceLifecycle(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "a.epub"), "aaaaaaaaaa")
|
||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||
|
||||
lib, err := Create(ctx, db, "Ebooks", "ebooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("first Scan: %v", err)
|
||||
}
|
||||
|
||||
// A present file has no missing timestamp.
|
||||
var since string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if since != "" {
|
||||
t.Errorf("present file missing_since = %q, want empty", since)
|
||||
}
|
||||
|
||||
// Delete b.epub: it goes missing and the timestamp is recorded.
|
||||
if err := os.Remove(filepath.Join(root, "b.epub")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("missing Scan: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if since == "" {
|
||||
t.Fatal("missing_since was not set on the transition into missing")
|
||||
}
|
||||
first := since
|
||||
|
||||
// A rescan must not refresh the age.
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("rescan: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if since != first {
|
||||
t.Errorf("missing_since reset on rescan: %q -> %q", first, since)
|
||||
}
|
||||
|
||||
// ListMissing surfaces the file with its age.
|
||||
entries, err := ListMissing(ctx, db, []int64{lib.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMissing: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("ListMissing returned %d entries, want 1", len(entries))
|
||||
}
|
||||
e := entries[0]
|
||||
if e.Title != "b" || e.Path != "b.epub" || e.Kind != "ebooks" || e.LibraryID != lib.ID {
|
||||
t.Errorf("unexpected missing entry: %+v", e)
|
||||
}
|
||||
if e.MissingSince != first {
|
||||
t.Errorf("entry missing_since = %q, want %q", e.MissingSince, first)
|
||||
}
|
||||
|
||||
// Restore the file: missing_since must be cleared, not left behind.
|
||||
writeFile(t, filepath.Join(root, "b.epub"), "bbbbbbbbbbbbbbbbbbbb")
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("restore Scan: %v", err)
|
||||
}
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT missing_since FROM media_files WHERE path = ?`, "b.epub").Scan(&since); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if since != "" {
|
||||
t.Errorf("missing_since after restore = %q, want empty", since)
|
||||
}
|
||||
if got, _ := ListMissing(ctx, db, []int64{lib.ID}); len(got) != 0 {
|
||||
t.Errorf("ListMissing after restore returned %d entries, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeMissingOnlyDeletesMissingItems(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
// Two audiobooks, each a folder of tracks.
|
||||
writeFile(t, filepath.Join(root, "Book A", "01.mp3"), "a1")
|
||||
writeFile(t, filepath.Join(root, "Book A", "02.mp3"), "a2")
|
||||
writeFile(t, filepath.Join(root, "Book B", "01.mp3"), "b1")
|
||||
writeFile(t, filepath.Join(root, "Book B", "02.mp3"), "b2")
|
||||
|
||||
lib, err := Create(ctx, db, "Audiobooks", "audiobooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
|
||||
// A second library with its own missing item, to prove purge is scoped.
|
||||
otherRoot := t.TempDir()
|
||||
writeFile(t, filepath.Join(otherRoot, "gone.epub"), "x")
|
||||
other, err := Create(ctx, db, "Other", "ebooks", otherRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("Create other: %v", err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
|
||||
t.Fatalf("Scan other: %v", err)
|
||||
}
|
||||
if err := os.Remove(filepath.Join(otherRoot, "gone.epub")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, other, testLogger()); err != nil {
|
||||
t.Fatalf("Scan other (missing): %v", err)
|
||||
}
|
||||
|
||||
// Remove one track from Book A: the item is not missing, only that file.
|
||||
// Remove Book B entirely: the item and both its files are missing.
|
||||
if err := os.Remove(filepath.Join(root, "Book A", "02.mp3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(root, "Book B")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Scan(ctx, db, lib, testLogger()); err != nil {
|
||||
t.Fatalf("second Scan: %v", err)
|
||||
}
|
||||
|
||||
entries, err := ListMissing(ctx, db, []int64{lib.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("ListMissing: %v", err)
|
||||
}
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("ListMissing returned %d entries, want 3 (Book A/02 + Book B/01 + Book B/02)", len(entries))
|
||||
}
|
||||
|
||||
// Purge must remove only Book B.
|
||||
n, err := PurgeMissing(ctx, db, lib.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeMissing: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("purged = %d, want 1 (only the fully missing item)", n)
|
||||
}
|
||||
|
||||
// Book A survives, with its present and missing files intact.
|
||||
var items int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items != 1 {
|
||||
t.Errorf("media_items after purge = %d, want 1", items)
|
||||
}
|
||||
var bookATitle string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Book A").Scan(&bookATitle); err != nil {
|
||||
t.Fatalf("a non-missing item was purged: %v", err)
|
||||
}
|
||||
var files int
|
||||
if err := db.QueryRowContext(ctx, `
|
||||
SELECT count(*) FROM media_files f JOIN media_items i ON i.id = f.item_id
|
||||
WHERE i.library_id = ? AND i.path = ?`, lib.ID, "Book A").Scan(&files); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if files != 2 {
|
||||
t.Errorf("Book A files after purge = %d, want 2", files)
|
||||
}
|
||||
|
||||
// The other library's missing item is untouched.
|
||||
var otherItems int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM media_items WHERE library_id = ?`, other.ID).Scan(&otherItems); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if otherItems != 1 {
|
||||
t.Errorf("other library items after purge = %d, want 1 (purge must be scoped)", otherItems)
|
||||
}
|
||||
|
||||
// A second purge has nothing left to remove.
|
||||
if n, err := PurgeMissing(ctx, db, lib.ID); err != nil || n != 0 {
|
||||
t.Errorf("second purge = %d, %v; want 0, nil", n, err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
|
|||
|
||||
err := commitBatch(ctx, db, func(tx *sql.Tx) error {
|
||||
for _, rel := range batch {
|
||||
if err := markFileMissing(ctx, tx, rel); err != nil {
|
||||
if err := markFileMissing(ctx, tx, rel, now); err != nil {
|
||||
res.addError(fmt.Sprintf("%s: %v", rel, err))
|
||||
}
|
||||
}
|
||||
|
|
@ -431,10 +431,10 @@ func loadKnownItems(ctx context.Context, db *sql.DB, libraryID int64) (map[strin
|
|||
func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string) (int64, error) {
|
||||
var id int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing, missing_since)
|
||||
VALUES (?, ?, ?, ?, ?, 0, '')
|
||||
ON CONFLICT (library_id, path)
|
||||
DO UPDATE SET missing = 0
|
||||
DO UPDATE SET missing = 0, missing_since = ''
|
||||
RETURNING id`,
|
||||
lib.ID, lib.Kind, key, title, strings.ToLower(title),
|
||||
).Scan(&id)
|
||||
|
|
@ -450,12 +450,13 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info
|
|||
// upsert never mentions those columns at all.
|
||||
if !probed {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0)
|
||||
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, '')
|
||||
ON CONFLICT (path)
|
||||
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
||||
mtime = excluded.mtime, inode = excluded.inode,
|
||||
container = excluded.container, missing = 0`,
|
||||
container = excluded.container, missing = 0,
|
||||
missing_since = ''`,
|
||||
itemID, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert file: %w", err)
|
||||
|
|
@ -464,13 +465,14 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, c candidate, info
|
|||
}
|
||||
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing,
|
||||
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing, missing_since,
|
||||
duration_ms, vcodec, acodec, width, height, probed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, '', ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (path)
|
||||
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
||||
mtime = excluded.mtime, inode = excluded.inode,
|
||||
container = excluded.container, missing = 0,
|
||||
missing_since = '',
|
||||
duration_ms = excluded.duration_ms, vcodec = excluded.vcodec,
|
||||
acodec = excluded.acodec, width = excluded.width,
|
||||
height = excluded.height, probed_at = excluded.probed_at`,
|
||||
|
|
@ -535,15 +537,28 @@ func probeable(kind string) bool {
|
|||
}
|
||||
}
|
||||
|
||||
// markItemMissing flags an item whose whole group has vanished. missing_since
|
||||
// records when it first disappeared, and is written only on that transition: a
|
||||
// rescan of an already-missing item must not refresh the age, or a file gone
|
||||
// for months would look "just now missing" on every pass. An empty timestamp
|
||||
// (a row that went missing before this column existed) is filled once, so the
|
||||
// age is still real rather than permanently blank.
|
||||
func markItemMissing(ctx context.Context, tx *sql.Tx, libraryID int64, key, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE media_items SET missing = 1
|
||||
WHERE library_id = ? AND path = ?`, libraryID, key)
|
||||
UPDATE media_items SET missing = 1, missing_since = ?
|
||||
WHERE library_id = ? AND path = ? AND (missing = 0 OR missing_since = '')`,
|
||||
now, libraryID, key)
|
||||
return err
|
||||
}
|
||||
|
||||
func markFileMissing(ctx context.Context, tx *sql.Tx, rel string) error {
|
||||
_, err := tx.ExecContext(ctx, `UPDATE media_files SET missing = 1 WHERE path = ?`, rel)
|
||||
// markFileMissing flags a file that vanished while its item survived. It obeys
|
||||
// the same transition rule as markItemMissing: the timestamp is set once and
|
||||
// never rewritten by a later scan.
|
||||
func markFileMissing(ctx context.Context, tx *sql.Tx, rel, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE media_files SET missing = 1, missing_since = ?
|
||||
WHERE path = ? AND (missing = 0 OR missing_since = '')`,
|
||||
now, rel)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
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 Libraries from './Libraries.svelte'
|
||||
import Users from './Users.svelte'
|
||||
import MissingFiles from './MissingFiles.svelte'
|
||||
import Settings from './Settings.svelte'
|
||||
|
||||
// The admin "Missing files" view is shell-local, so the shell widens the
|
||||
// shared View union here rather than forking nav.ts for one admin screen.
|
||||
type ShellView = View | { name: 'missing' }
|
||||
|
||||
let searchBox = $state('')
|
||||
let scrolled = $state(false)
|
||||
let contentEl = $state<HTMLDivElement>()
|
||||
|
|
@ -19,6 +24,7 @@
|
|||
// A stable key for the current view, so navigating transitions the content
|
||||
// instead of swapping it instantly.
|
||||
const viewKey = $derived(JSON.stringify($view))
|
||||
const current = $derived($view as ShellView)
|
||||
|
||||
function onScroll() {
|
||||
scrolled = (contentEl?.scrollTop ?? 0) > 8
|
||||
|
|
@ -43,7 +49,7 @@
|
|||
if (q) go({ name: 'search', query: q })
|
||||
}
|
||||
|
||||
function title(v: View): string {
|
||||
function title(v: ShellView): string {
|
||||
switch (v.name) {
|
||||
case 'home':
|
||||
return 'Home'
|
||||
|
|
@ -60,6 +66,8 @@
|
|||
return 'Libraries'
|
||||
case 'users':
|
||||
return 'Users'
|
||||
case 'missing':
|
||||
return 'Missing files'
|
||||
case 'settings':
|
||||
return 'Appearance'
|
||||
}
|
||||
|
|
@ -104,6 +112,13 @@
|
|||
<button class="navlink" class:active={$view.name === 'users'} onclick={() => go({ name: 'users' })}>
|
||||
<span>Users</span>
|
||||
</button>
|
||||
<button
|
||||
class="navlink"
|
||||
class:active={current.name === 'missing'}
|
||||
onclick={() => go({ name: 'missing' } as unknown as View)}
|
||||
>
|
||||
<span>Missing files</span>
|
||||
</button>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
|
|
@ -157,6 +172,8 @@
|
|||
<Libraries />
|
||||
{:else if $view.name === 'users'}
|
||||
<Users />
|
||||
{:else if current.name === 'missing'}
|
||||
<MissingFiles />
|
||||
{:else if $view.name === 'settings'}
|
||||
<Settings />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import { onMount, onDestroy } from 'svelte'
|
||||
import { api, ApiError, type Item, type MediaFile } from '../api'
|
||||
import * as playback from '../playback'
|
||||
import { clientEngines, probeFile } from '../capabilities'
|
||||
import {
|
||||
setNowPlaying,
|
||||
setHandlers,
|
||||
|
|
@ -49,6 +50,15 @@
|
|||
let undecodable = $state(false)
|
||||
let videoDecoded = $state(false)
|
||||
|
||||
// `serverNote` is the server's advisory verdict for the item's first file
|
||||
// (api.resolvePlayback). It SUPPLEMENTS the element watchdog, never replaces
|
||||
// it: it is shown only until the element reports a real picture, and the
|
||||
// element's own verdict always wins once it has one.
|
||||
let serverNote = $state('')
|
||||
// `qualityNote` is this client's MediaCapabilities refinement — a codec it can
|
||||
// decode, but not smoothly. Advisory only; it never blocks playback.
|
||||
let qualityNote = $state('')
|
||||
|
||||
let saveTimer: number | undefined
|
||||
let verifyTimer: number | undefined
|
||||
let loadWatchdog: number | undefined
|
||||
|
|
@ -59,6 +69,12 @@
|
|||
const file = $derived(files[index] ?? null)
|
||||
const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined)
|
||||
const srcType = $derived(file ? playback.sourceType(file) : '')
|
||||
// The server resolves an ITEM using its first file's codec facts, so its
|
||||
// advisory applies only while that first file is selected. Once the element
|
||||
// has decoded a picture the server note is hidden regardless: the element is
|
||||
// the source of truth.
|
||||
const serverApplies = $derived(files.length > 0 && index === 0)
|
||||
const displayedNotice = $derived(notice || (serverApplies && !videoDecoded ? serverNote : ''))
|
||||
// `type` belongs on a <source>, but putting it there lets the browser SKIP a
|
||||
// file whose canPlayType answer is wrong — it would refuse to try mov+h264
|
||||
// (which plays) and would never even parse HEVC, so `videoWidth` stays 0 and
|
||||
|
|
@ -119,6 +135,10 @@
|
|||
}
|
||||
publishNowPlaying()
|
||||
registerHandlers()
|
||||
// Ask the server (and this client's own decoder) what they make of the
|
||||
// file. Best-effort: the element watchdog below is still the authority,
|
||||
// so this never blocks the player and a failure is never shown as an error.
|
||||
void refinePlayback()
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'could not open this video'
|
||||
} finally {
|
||||
|
|
@ -194,6 +214,42 @@
|
|||
return 'This browser cannot decode the video track in this file, so you would only hear audio over a blank picture. Vellum does not transcode.'
|
||||
}
|
||||
|
||||
// Ask the server which of this client's engines can play the item, then let
|
||||
// the client's own decoder sharpen the answer. Both results are ADVISORY: the
|
||||
// loaded element is still the source of truth (see checkDecoded / onError),
|
||||
// so a server or MediaCapabilities failure must never surface as an error and
|
||||
// must never stop the file from being tried.
|
||||
async function refinePlayback() {
|
||||
try {
|
||||
const engines = await clientEngines()
|
||||
const res = await api.resolvePlayback(itemId, engines)
|
||||
if (!res.playable && res.message) serverNote = res.message
|
||||
} catch {
|
||||
// Endpoint absent (older server) or unreachable: the element watchdog
|
||||
// still covers the honest path.
|
||||
}
|
||||
|
||||
const f = files[index] ?? files[0]
|
||||
if (!f) return
|
||||
try {
|
||||
const verdict = await probeFile(f)
|
||||
if (verdict.unknown) return
|
||||
if (!verdict.supported) {
|
||||
// The client's own hardware check disagrees with the server baseline:
|
||||
// report it rather than promising a picture that will not come.
|
||||
if (!serverNote) {
|
||||
serverNote =
|
||||
'This browser reports it cannot decode this file’s video, so you would only hear audio over a blank picture.'
|
||||
}
|
||||
} else if (!verdict.smooth) {
|
||||
// Supported, but the client says it will stutter or drain the battery.
|
||||
qualityNote = 'This browser can decode this file, but its own hardware check says it may not play smoothly.'
|
||||
}
|
||||
} catch {
|
||||
// Best-effort refinement only.
|
||||
}
|
||||
}
|
||||
|
||||
function showUndecodable(f: MediaFile | null, fallback = '') {
|
||||
notice = honestNotice(f) || fallback
|
||||
undecodable = true
|
||||
|
|
@ -484,10 +540,10 @@
|
|||
<a class="btn btn-ghost dl" href={api.fileUrl(itemId)} download>Download</a>
|
||||
</div>
|
||||
|
||||
{#if notice}
|
||||
{#if displayedNotice}
|
||||
<div class="notice" role="alert">
|
||||
<strong>Video cannot play here</strong>
|
||||
<p>{notice}</p>
|
||||
<strong>{undecodable ? 'Video cannot play here' : 'Playback may not work here'}</strong>
|
||||
<p>{displayedNotice}</p>
|
||||
<a class="btn" href={api.fileUrl(itemId)} download>Download the file</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -520,6 +576,10 @@
|
|||
<p class="ok-note" aria-live="polite">Playing video directly — no transcoding.</p>
|
||||
{/if}
|
||||
|
||||
{#if qualityNote && !undecodable}
|
||||
<p class="quality-note muted" aria-live="polite">{qualityNote}</p>
|
||||
{/if}
|
||||
|
||||
<input
|
||||
class="seek"
|
||||
type="range"
|
||||
|
|
@ -641,6 +701,10 @@
|
|||
font-size: var(--fs-sm);
|
||||
color: var(--success);
|
||||
}
|
||||
.quality-note {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.seek {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue