diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index dba1f93..bc4ca02 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -121,6 +121,24 @@ export const api = { scanLibrary: (id: number) => request<{ queued: number; job_id: number }>('POST', `/api/v1/libraries/${id}/scan`), + // ── playback engines ── + engines: () => request<{ engines: EngineSupport[] }>('GET', '/api/v1/playback/engines'), + + resolvePlayback: (itemId: number, engines: string[]) => + request('POST', '/api/v1/playback/resolve', { + item_id: itemId, + engines, + }), + + // ── missing files ── + missing: (libraryId?: number) => { + const q = libraryId ? `?library_id=${libraryId}` : '' + return request<{ items: MissingEntry[]; total: number }>('GET', `/api/v1/missing${q}`) + }, + + purgeMissing: (libraryId: number) => + request<{ purged: number }>('POST', `/api/v1/libraries/${libraryId}/purge`), + scanStatus: (id: number) => request<{ state: 'queued' | 'running' | 'done' | 'failed' | 'idle' @@ -298,6 +316,47 @@ export interface ItemDetail { progress?: Progress } +// ── playback engines ──────────────────────────────────────────────────── +// Playability is a property of the CLIENT, so a client declares the engines it +// has and the server matches them against the file's codec facts. A browser +// offers "html5"; a native client offers "mpv", "vlc", "mdk" as well. +export interface EngineSupport { + id: string + name: string + kind: 'browser' | 'native' + /** Lowercase container extensions it can open, e.g. "mkv". */ + containers: string[] + /** Video codec names it can decode, e.g. "h264", "hevc". */ + vcodecs: string[] + /** Audio codec names it can decode. */ + acodecs: string[] +} + +export interface PlaybackResolution { + playable: boolean + /** The engine that should play it, or null when nothing can. */ + engine: string | null + /** Machine-readable cause when not playable, e.g. "vcodec:hevc". */ + reason: string + /** Human text for the UI. Empty when playable. */ + message: string +} + +// ── missing files ─────────────────────────────────────────────────────── +// A scan never deletes: a file that vanishes is marked missing so its progress +// survives a temporarily unmounted drive. Nothing is removed until an admin +// explicitly purges, which is what these two calls are for. +export interface MissingEntry { + item_id: number + file_id: number + library_id: number + kind: string + title: string + path: string + /** When the file was first seen missing, RFC3339. */ + missing_since: string +} + export interface ScanResult { library_id: number scanned: number diff --git a/web/src/lib/components/VideoPlayer.svelte b/web/src/lib/components/VideoPlayer.svelte index 615e050..579da4f 100644 --- a/web/src/lib/components/VideoPlayer.svelte +++ b/web/src/lib/components/VideoPlayer.svelte @@ -23,12 +23,10 @@ // conclude the video track is undecodable. Long enough for a slow start, short // enough that a black frame is never mistaken for a working picture. const DECODE_GRACE_MS = 1200 - // How long to wait for the element to say ANYTHING after load() before we - // conclude it rejected the source outright. Needed because an explicit `type` - // attribute the browser cannot satisfy makes it refuse the resource during - // selection: neither `loadedmetadata` NOR `error` ever fires, so a player that - // only reacts to those two events shows 0:00 and a Play button forever — the - // silently-broken state this component exists to avoid. + // Belt-and-braces: if the element says nothing at all after a source change + // (neither loadedmetadata nor error), it rejected the resource during + // selection. Only a timer will notice, and showing the honest notice is the + // whole point of this component. const LOAD_GRACE_MS = 2500 let video = $state() @@ -61,25 +59,30 @@ const file = $derived(files[index] ?? null) const src = $derived(file ? api.fileUrlById(itemId, file.id) : undefined) const srcType = $derived(file ? playback.sourceType(file) : '') + // `type` belongs on a , but putting it there lets the browser SKIP a + // file whose canPlayType answer is wrong — it would refuse to try mov+h264 + // (which plays) and would never even parse HEVC, so `videoWidth` stays 0 and + // no `error` fires; the honest path below would never see it. Setting src on + // the element makes the browser load the file and report `videoWidth`/`error` + // for real, which is the measured behaviour playback.ts describes. The type is + // kept only as an ignored hint. + const videoAttrs = $derived(srcType ? { type: srcType } : {}) - // A child only feeds the element's resource-selection algorithm when - // it is first attached; swapping the src afterwards needs an explicit load(). - // This is what lets the picker switch between a multi-file item's files. + // Setting the src attribute restarts the element's load algorithm, so the + // picker can switch between a multi-file item's files without an explicit + // load(). The watchdog covers the case where the element never responds. $effect(() => { src const el = video if (!el) return resetDecodeState() - el.load() - // Arm the watchdog: if the element neither loads metadata nor errors, the - // source was rejected during selection and only this timer will notice. clearWatchdog() loadWatchdog = window.setTimeout(() => { loadWatchdog = undefined const v = video if (!v) return if (playback.hasDecodedVideo(v)) return - // readyState < HAVE_METADATA means it never even parsed the container. + // readyState < HAVE_METADATA means it never parsed the container at all. if (v.readyState < 1) showUndecodable(file) }, LOAD_GRACE_MS) }) @@ -492,10 +495,14 @@
+ tracks to offer, so there is nothing to caption. src is on the element + (not a ) so the browser actually loads the file and reports + videoWidth / error; see videoAttrs above. --> + >
{#if videoDecoded}