Vellum/web/src/lib/api.ts
mithral 915f390156 Video player: load the file, don't let the browser skip it
Follow-up to 785d3ed. The committed version put the codec `type` on a <source>
child. That is the obvious thing to do and it is wrong: an explicit type lets
Chrome SKIP the resource during selection whenever canPlayType disagrees with
reality, and canPlayType is wrong in both directions.

The cost was measurable. mov+h264 has canPlayType('video/quicktime') === "no",
so the browser refused to try it — a file that PLAYS would have been reported as
unplayable. Verified now at 640x360, readyState 4.

Setting src on the element instead makes the browser actually load the file and
report videoWidth / error honestly, which is what playback.ts's measured notes
describe:
  mov+h264  -> loads, videoWidth 640   -> plays
  mp4+hevc  -> loads, videoWidth 0     -> detected by the decode check
  avi+mpeg4 -> error code 4            -> detected by the error path
The `type` is kept only as an ignored hint, and the load watchdog still covers
the case where the element says nothing at all. All three detection paths are
now reachable, which is the point: for HEVC, Chrome plays audio over a blank
frame with no error, and that must never be mistaken for working playback.

Process note, against the coordinator: I edited this file while its agent still
owned it, and the edit introduced the mov+h264 regression above. The agent
reverted it and kept the watchdog, which was the useful half. Do not edit a file
an agent owns mid-task; hand the finding over instead.
2026-10-11 14:20:15 -04:00

370 lines
11 KiB
TypeScript

// Typed client for the Vellum HTTP API. Every response shape here mirrors a
// Go handler; keep them in sync when endpoints change.
export interface User {
id: number
username: string
display_name: string
is_admin: boolean
created_at: string
}
export interface Library {
id: number
name: string
kind: string
root_path: string
created_at: string
}
export interface MediaType {
Kind: string
Name: string
Extensions: string[]
}
export interface Item {
id: number
library_id: number
kind: string
path: string
title: string
year: number
added_at: string
missing?: boolean
}
export interface Progress {
item_id: number
file_id: number
kind: string
position: number
duration: number
percent: number
finished: boolean
updated_at: string
}
export interface EbookMetadata {
title: string
authors: string[] | null
language: string
identifier: string
publisher: string
date: string
description: string
spine_length: number
}
export class ApiError extends Error {
status: number
constructor(status: number, message: string) {
super(message)
this.status = status
this.name = 'ApiError'
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(path, {
method,
credentials: 'same-origin',
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
})
if (res.status === 204) return undefined as T
const text = await res.text()
let parsed: unknown = undefined
if (text) {
try {
parsed = JSON.parse(text)
} catch {
// Fall through to the status-based error below.
}
}
if (!res.ok) {
const message =
parsed && typeof parsed === 'object' && 'error' in parsed
? String((parsed as { error: unknown }).error)
: `request failed (${res.status})`
throw new ApiError(res.status, message)
}
return parsed as T
}
export const api = {
login: (username: string, password: string) =>
request<{ user: User; expires_in: number }>('POST', '/api/v1/auth/login', { username, password }),
logout: () => request<{ status: string }>('POST', '/api/v1/auth/logout'),
me: () => request<{ user: User; visible_libraries: number[] }>('GET', '/api/v1/auth/me'),
changePassword: (current_password: string, new_password: string) =>
request<{ status: string }>('POST', '/api/v1/auth/password', { current_password, new_password }),
mediaTypes: () => request<{ media_types: MediaType[] }>('GET', '/api/v1/media-types'),
libraries: () => request<{ libraries: Library[] }>('GET', '/api/v1/libraries'),
library: (id: number) =>
request<{ library: Library; item_count: number; reachable: boolean }>('GET', `/api/v1/libraries/${id}`),
createLibrary: (name: string, kind: string, root_path: string) =>
request<{ library: Library }>('POST', '/api/v1/libraries', { name, kind, root_path }),
deleteLibrary: (id: number) => request<void>('DELETE', `/api/v1/libraries/${id}`),
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<PlaybackResolution>('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'
result?: {
scanned: number
added: number
updated: number
restored: number
missing: number
unchanged: number
duration_ms: number
errors?: string[]
}
error?: string
}>('GET', `/api/v1/libraries/${id}/scan/status`),
enrichLibrary: (id: number) =>
request<{ queued: number }>('POST', `/api/v1/libraries/${id}/enrich`),
itemMetadata: (id: number) =>
request<{
item_id: number
provider_ids: { provider: string; external_id: string }[]
fetch_history: { provider: string; status: string; detail: string; fetched_at: string }[]
}>('GET', `/api/v1/items/${id}/metadata`),
items: (libraryId: number, limit = 200) =>
request<{ items: Item[]; limit: number }>('GET', `/api/v1/libraries/${libraryId}/items?limit=${limit}`),
item: (id: number) => request<ItemDetail>('GET', `/api/v1/items/${id}`),
home: () => request<Home>('GET', '/api/v1/home'),
recordView: (id: number) =>
request<{ item_id: number; views: number }>('POST', `/api/v1/items/${id}/view`),
game: (id: number) =>
request<{
item_id: number
platform: string
core: string
playable: boolean
saves: { slot: string; size: number; updated_at: string }[]
}>('GET', `/api/v1/items/${id}/game`),
saveUrl: (id: number, slot = 'default') => `/api/v1/items/${id}/saves/${slot}`,
deleteSave: (id: number, slot = 'default') =>
request<void>('DELETE', `/api/v1/items/${id}/saves/${slot}`),
listItems: (opts: { kind?: string; libraryId?: number; limit?: number; offset?: number } = {}) => {
const p = new URLSearchParams()
if (opts.kind) p.set('kind', opts.kind)
if (opts.libraryId) p.set('library_id', String(opts.libraryId))
if (opts.limit) p.set('limit', String(opts.limit))
if (opts.offset) p.set('offset', String(opts.offset))
return request<{ items: Item[]; total: number; offset: number; limit: number }>(
'GET',
`/api/v1/items?${p.toString()}`,
)
},
ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`),
pages: (id: number) =>
request<{ pages: { index: number; name: string }[]; count: number }>('GET', `/api/v1/items/${id}/pages`),
pageUrl: (id: number, page: number) => `/api/v1/items/${id}/pages/${page}`,
coverUrl: (id: number) => `/api/v1/items/${id}/cover`,
fileUrl: (id: number) => `/api/v1/items/${id}/file`,
fileUrlById: (id: number, fileId: number) => `/api/v1/items/${id}/files/${fileId}`,
progress: (id: number) => request<{ progress: Progress | null }>('GET', `/api/v1/items/${id}/progress`),
putProgress: (
id: number,
p: { file_id?: number; position?: number; duration?: number; percent?: number; finished?: boolean },
) => request<{ progress: Progress }>('PUT', `/api/v1/items/${id}/progress`, p),
recent: () => request<{ progress: Progress[] }>('GET', '/api/v1/progress/recent'),
search: (q: string) =>
request<{ results: SearchResult[]; query: string }>('GET', `/api/v1/search?q=${encodeURIComponent(q)}`),
users: () => request<{ users: User[] }>('GET', '/api/v1/users'),
createUser: (username: string, password: string, display_name: string, is_admin: boolean) =>
request<{ user: User }>('POST', '/api/v1/users', { username, password, display_name, is_admin }),
userGrants: (userId: number) =>
request<{ grants: { library_id: number; can_read: boolean; can_download: boolean }[] }>(
'GET',
`/api/v1/users/${userId}/grants`,
),
putGrant: (userId: number, libraryId: number, canRead: boolean, canDownload: boolean) =>
request<{ user_id: number; library_id: number; can_read: boolean; can_download: boolean }>(
'PUT',
`/api/v1/users/${userId}/grants/${libraryId}`,
{ can_read: canRead, can_download: canDownload },
),
deleteGrant: (userId: number, libraryId: number) =>
request<void>('DELETE', `/api/v1/users/${userId}/grants/${libraryId}`),
}
export interface SearchResult {
id: number
library_id: number
kind: string
path: string
title: string
}
export interface ShelfItem {
id: number
library_id: number
kind: string
title: string
percent: number
file_id: number
views: number
updated_at: string
}
export interface Suggestion {
id: number
library_id: number
kind: string
title: string
year: number
reason: string
}
export interface Home {
continue: ShelfItem[] | null
most_viewed: ShelfItem[] | null
suggestions: Suggestion[] | null
recent: ShelfItem[] | null
explore: ShelfItem[] | null
counts_by_kind: Record<string, number>
library_count: number
total_items: number
}
// MediaFile carries the ffprobe facts about one file on disk.
//
// The codec fields are what make "direct play only" workable: playability is a
// property of the CLIENT, not the file, so the server reports what is in the
// file and each client decides for itself. An empty vcodec means there is no
// video track (audio, or a comic).
export interface MediaFile {
id: number
path: string
size: number
container: string
missing: boolean
duration_ms: number
/** ffprobe codec name, e.g. "h264", "hevc", "vp9", "av1". "" when absent. */
vcodec: string
/** ffprobe codec name, e.g. "aac", "opus", "flac", "mp3". "" when absent. */
acodec: string
width: number
height: number
}
export interface ItemDetail {
item: Item
files: MediaFile[]
has_cover: boolean
authors: string[] | null
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
added: number
updated: number
restored: number
missing: number
unchanged: number
duration_ms: number
errors?: string[]
}