// 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(method: string, path: string, body?: unknown): Promise { 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('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('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('GET', `/api/v1/items/${id}`), home: () => request('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('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('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 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[] }