/** * API Client Library * */ // ============================================================================= // Types // ============================================================================= type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; type Endpoint = `${Method} /${string}` | `/${string}`; /** * Standard API Response structure from the server */ interface APIResponse { success: boolean; detail: string | null; data: T | null; error: { code: string; message: string; issues?: any; } | null; } // ============================================================================= // API Error Class // ============================================================================= /** * API Error thrown when the server returns an error response */ export class APIError extends Error { status: number; code: string; detail: string | null; issues?: any; constructor( status: number, code: string, message: string, detail: string | null = null, issues?: any ) { super(message); this.name = 'APIError'; this.status = status; this.code = code; this.detail = detail; this.issues = issues; } /** * Check if this is a specific error code */ is(code: string): boolean { return this.code === code; } } // ============================================================================= // Request Options // ============================================================================= type RequestOptions = Omit & { body?: Record | FormData | string; }; // ============================================================================= // API Client // ============================================================================= /** * Make a type-safe API request * * @param endpoint - API endpoint (e.g., "GET /quizzes" or "/quizzes") * @param options - Request options * @returns The data from a successful response * @throws APIError if the server returns an error * * @example * // GET request * const quiz = await api('/quizzes/123'); * * // POST with body * const newQuiz = await api('POST /quizzes', { * body: { title: 'My Quiz', description: 'A fun quiz' } * }); * * // Handle errors * try { * await api('POST /auth/login', { body: { email, password } }); * } catch (err) { * if (err instanceof APIError) { * if (err.is('UNAUTHORIZED')) { * // Invalid credentials * } * if (err.is('VALIDATION_ERROR')) { * const issues = err.issues; * // Handle validation issues * } * } * } */ export async function api( endpoint: Endpoint, options: RequestOptions = {} ): Promise { const { body, ...fetchOptions } = options; // Parse endpoint for method and path const [method, path] = endpoint.includes(' /') ? (endpoint.split(' /') as [Method, string]) : (['GET', endpoint.slice(1)] as [Method, string]); const url = `/api/v1/${path}`; // Build request const headers = new Headers(fetchOptions.headers); const request: RequestInit = { method, credentials: 'include', ...fetchOptions, headers, }; // Handle body if (body !== undefined) { if (body instanceof FormData) { request.body = body; } else if (typeof body === 'string') { request.body = body; } else { headers.set('Content-Type', 'application/json'); request.body = JSON.stringify(body); } } // Make request const response = await fetch(url, request); // Handle 204 No Content if (response.status === 204) { return null as T; } // Check content type const contentType = response.headers.get('Content-Type') ?? ''; if (!contentType.includes('application/json')) { // A redirect to the login page means the session expired server-side. // Navigate the browser to login (full reload clears all query cache). if (response.url.includes('/login')) { const next = encodeURIComponent( window.location.pathname + window.location.search ); window.location.href = `/login?next=${next}`; await new Promise(() => {}); } throw new Error( `Expected JSON response but got ${contentType} response of ${response.status} ${response.statusText}` ); } // Parse response const json = (await response.json()) as APIResponse; // Handle error response if (!json.success) { const errorCode = json.error?.code || 'UNKNOWN_ERROR'; const errorMessage = json.error?.message || 'An unknown error occurred'; const detail = json.detail; const issues = json.error?.issues; throw new APIError( response.status, errorCode, errorMessage, detail, issues ); } // Return data (null is a valid response) return json.data as T; } // ============================================================================= // API Helper Functions // ============================================================================= // Import types from core package (types only) import type { UserData, ParsedStream } from '@aiostreams/core'; /** * User configuration response types */ interface LoadUserResponse { userData: UserData; encryptedPassword: string; } interface CreateUserResponse { uuid: string; encryptedPassword: string; } interface UpdateUserResponse { uuid: string; userData: UserData; } interface ResolvePatternsResponse { patterns: { name: string; pattern: string; score?: number }[]; } interface ResolveSyncedResponse { patterns?: { name: string; pattern: string; score?: number }[]; expressions?: { expression: string; name?: string; score?: number; enabled?: boolean; }[]; errors?: { url: string; error: string }[]; } interface FormatStreamResponse { name: string; description: string; } interface CatalogInfo { id: string; type: string; name: string; hideable: boolean; searchable: boolean; addonName: string; } interface GDriveTokenResponse { accessToken: string; refreshToken: string; } /** * Auth / session */ export interface SessionUser { username: string; isAdmin: boolean; } export async function login(username: string, password: string) { return api('POST /auth/login', { body: { username, password }, }); } export async function logout() { return api('POST /auth/logout'); } export async function getSession() { return api('/auth/me'); } /** * Build the value for an `Authorization: Basic base64(uuid:password)` header. * Used by all User API requests that require credentials. */ function basicAuthHeader(uuid: string, password: string): string { // btoa requires latin-1; encode as UTF-8 bytes first to support non-ASCII chars. const bytes = new TextEncoder().encode(`${uuid}:${password}`); let binary = ''; for (const byte of bytes) { binary += String.fromCharCode(byte); } return `Basic ${btoa(binary)}`; } /** * Load user configuration */ export async function loadUserConfig(uuid: string, password: string) { return api('GET /user', { headers: { Authorization: basicAuthHeader(uuid, password) }, }); } export async function loadRawUserConfig(uuid: string, password: string) { return api('GET /user?raw=true', { headers: { Authorization: basicAuthHeader(uuid, password) }, }); } /** * Create user configuration */ export async function createUserConfig(config: UserData, password: string) { return api('POST /user', { body: { config, password }, }); } /** * Update user configuration */ export async function updateUserConfig( uuid: string, config: UserData, password: string ) { return api('PUT /user', { body: { config }, headers: { Authorization: basicAuthHeader(uuid, password) }, }); } /** * Verify a UUID + password pair (used when linking a parent config) */ export async function verifyParentConfig( uuid: string, password: string ): Promise<{ uuid: string; createdAt: string }> { return api<{ uuid: string; createdAt: string }>('POST /user/verify', { headers: { Authorization: basicAuthHeader(uuid, password) }, }); } /** * Delete user */ export async function deleteUserConfig(uuid: string, password: string) { return api('DELETE /user', { headers: { Authorization: basicAuthHeader(uuid, password) }, }); } /** * Change user password */ export async function changePassword( uuid: string, currentPassword: string, newPassword: string ) { return api<{ encryptedPassword: string }>('POST /user/password', { body: { newPassword }, headers: { Authorization: basicAuthHeader(uuid, currentPassword) }, }); } /** * Resolve synced items (regex patterns and/or stream expressions) from URLs */ export async function resolveSynced( options: { regexUrls?: string[]; selUrls?: string[]; }, credentials?: { uuid: string; password: string } ) { return api('POST /sync/resolve', { body: { regexUrls: options.regexUrls, selUrls: options.selUrls, uuid: credentials?.uuid, password: credentials?.password, }, }); } /** * Resolve regex patterns from URLs (convenience wrapper) */ export async function resolveRegexPatterns( urls: string[], credentials?: { uuid: string; password: string } ) { const result = await resolveSynced({ regexUrls: urls }, credentials); return { patterns: result.patterns || [], errors: result.errors }; } /** * Resolve stream expressions from URLs (convenience wrapper) */ export async function resolveStreamExpressions( urls: string[], credentials?: { uuid: string; password: string } ) { const result = await resolveSynced({ selUrls: urls }, credentials); return { expressions: result.expressions || [], errors: result.errors }; } /** * Format stream for display */ export async function getFormattedStream(stream: ParsedStream, context?: any) { return api('POST /format', { body: { stream, context }, }); } /** * Get catalogs for user data */ export async function fetchCatalogs(userData: UserData) { return api('POST /catalogs', { body: { userData }, }); } /** * Exchange Google Drive OAuth code for tokens */ export async function exchangeGDriveCode(code: string) { return api('POST /oauth/exchange/gdrive', { body: { code }, }); } /** * Get templates */ export async function fetchTemplates() { return api('GET /templates'); } /** * Per-user analytics breakdown for the configure-page Stats tab. * * The shape mirrors `AnalyticsRepository.userBreakdown(...)` on the server. * `range` is clamped server-side to whatever the configured raw retention * window allows (typically 7d). */ export interface UserAnalyticsAddon { presetType: string; instanceHash: string; /** User-set addon display name (latest in window) — may be null for very * old rows captured before the column existed. */ addonName: string | null; requests: number; status: { ok: number; error: number; empty: number }; avgLatencyMs: number | null; avgRawCount: number; avgFinalContribution: number; finalShare: number; cutOffRate: number; errorRate: number; emptyRate: number; redundant: boolean; slow: boolean; } export interface UserAnalyticsService { serviceId: string; finalCount: number; cachedCount: number; uncachedCount: number; cachedShare: number; contributingAddons: string[]; } export interface UserAnalyticsResponse { range: '24h' | '7d'; windowMs: number; generatedAt: number; totals: { requests: number; finalCountAvg: number; cutOffRate: number; errorRate: number; mergedRequests: number; }; perAddon: UserAnalyticsAddon[]; perService: UserAnalyticsService[]; latencyLeaderboard: Array<{ presetType: string; instanceHash: string; addonName: string | null; avgLatencyMs: number; }>; } export async function fetchUserAnalytics( uuid: string, password: string, range: '24h' | '7d' ) { return api(`GET /user/analytics?range=${range}`, { headers: { Authorization: basicAuthHeader(uuid, password) }, }); } /** * Fetch a Stremio manifest from a URL */ export async function fetchManifest(url: string): Promise { const response = await fetch(url); if (!response.ok) { throw new Error( `Failed to fetch manifest: ${response.status} ${response.statusText}` ); } return response.json(); } export type { ParsedStream, LoadUserResponse, CreateUserResponse, UpdateUserResponse, ResolvePatternsResponse, ResolveSyncedResponse, FormatStreamResponse, CatalogInfo, GDriveTokenResponse, };