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.
370 lines
11 KiB
TypeScript
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[]
|
|
}
|