// Admin-only API client. Every endpoint here is guarded by // RequireAdmin on the backend (session cookie only — bearer tokens // won't reach these routes). 403s thrown here propagate up to the // /admin layout, which renders the framework error page. import { request, type Page } from './client'; import type { User } from './auth'; import type { MangaDetail } from './mangas'; import type { Chapter } from './chapters'; // ---- users ----------------------------------------------------------------- export type AdminUsersPage = { items: User[]; page: Page; }; export type ListAdminUsersOptions = { search?: string; limit?: number; offset?: number; }; export async function listAdminUsers( opts: ListAdminUsersOptions = {} ): Promise { const params = new URLSearchParams(); if (opts.search) params.set('search', opts.search); if (opts.limit != null) params.set('limit', String(opts.limit)); if (opts.offset != null) params.set('offset', String(opts.offset)); const qs = params.toString(); return request(`/v1/admin/users${qs ? `?${qs}` : ''}`); } export async function deleteAdminUser(id: string): Promise { await request(`/v1/admin/users/${encodeURIComponent(id)}`, { method: 'DELETE' }); } export async function setUserAdmin(id: string, isAdmin: boolean): Promise { return request(`/v1/admin/users/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ is_admin: isAdmin }) }); } export type CreateAdminUserInput = { username: string; password: string; is_admin?: boolean; }; /** POST /v1/admin/users — admin-initiated account creation. Works * regardless of the ALLOW_SELF_REGISTER toggle, since the entire * point is for an admin to enroll someone when self-register is off. */ export async function createAdminUser(input: CreateAdminUserInput): Promise { return request('/v1/admin/users', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input) }); } // ---- mangas / chapters with sync state ------------------------------------- export type MangaSyncState = 'in_progress' | 'dropped' | 'synced'; export type AdminMangaRow = { id: string; title: string; status: string; cover_image_path: string | null; created_at: string; updated_at: string; sync_state: MangaSyncState; chapter_count: number; latest_seen_at: string | null; }; export type AdminMangasPage = { items: AdminMangaRow[]; page: Page; }; export type ListAdminMangasOptions = { search?: string; syncState?: MangaSyncState; limit?: number; offset?: number; }; export async function listAdminMangas( opts: ListAdminMangasOptions = {} ): Promise { const params = new URLSearchParams(); if (opts.search) params.set('search', opts.search); if (opts.syncState) params.set('sync_state', opts.syncState); if (opts.limit != null) params.set('limit', String(opts.limit)); if (opts.offset != null) params.set('offset', String(opts.offset)); const qs = params.toString(); return request(`/v1/admin/mangas${qs ? `?${qs}` : ''}`); } export type ChapterSyncState = | 'downloading' | 'dropped' | 'failed' | 'not_downloaded' | 'synced'; export type AdminChapterRow = { id: string; manga_id: string; number: number; title: string | null; page_count: number; created_at: string; sync_state: ChapterSyncState; latest_seen_at: string | null; }; export type AdminChaptersPage = { items: AdminChapterRow[]; page: Page; }; export type ListAdminChaptersOptions = { limit?: number; offset?: number; }; export async function listAdminChapters( mangaId: string, opts: ListAdminChaptersOptions = {} ): Promise { const params = new URLSearchParams(); if (opts.limit != null) params.set('limit', String(opts.limit)); if (opts.offset != null) params.set('offset', String(opts.offset)); const qs = params.toString(); return request( `/v1/admin/mangas/${encodeURIComponent(mangaId)}/chapters${qs ? `?${qs}` : ''}` ); } // ---- system ---------------------------------------------------------------- export type DiskStats = { total_bytes: number; used_bytes: number; free_bytes: number; percent_used: number; }; export type MemoryStats = { total_bytes: number; used_bytes: number; percent_used: number; }; export type CpuStats = { percent_used: number; }; export type Alert = { level: 'warning'; message: string; }; export type SystemStats = { disk: DiskStats | null; memory: MemoryStats; cpu: CpuStats; alerts: Alert[]; }; export async function getSystemStats(): Promise { return request('/v1/admin/system'); } // ---- force resync ---------------------------------------------------------- export type MangaResyncResponse = { manga: MangaDetail; metadata_status: 'new' | 'updated' | 'unchanged'; cover_fetched: boolean; }; export type ChapterResyncResponse = { chapter: Chapter; outcome: 'fetched' | 'skipped'; /** Page count when `outcome === 'fetched'`; null when skipped. */ pages: number | null; }; /** POST /v1/admin/mangas/:id/resync — refetches metadata + cover from * the manga's live crawler source. Long-running (one HTTP request per * Chromium nav + image download), so the UI should disable the trigger * and surface progress. */ export async function resyncManga(id: string): Promise { return request( `/v1/admin/mangas/${encodeURIComponent(id)}/resync`, { method: 'POST' } ); } /** POST /v1/admin/chapters/:id/resync — force-refetches a chapter's * pages even if `page_count > 0`. Same long-running caveat as * `resyncManga`. */ export async function resyncChapter(id: string): Promise { return request( `/v1/admin/chapters/${encodeURIComponent(id)}/resync`, { method: 'POST' } ); } // ---- crawler observability + control --------------------------------------- /** Current daemon activity. Discriminated on `state`. */ export type CrawlerPhase = | { state: 'idle'; next_fire: string | null } | { state: 'walking_list' } | { state: 'fetching_metadata'; index: number; total: number | null; title: string } | { state: 'cover_backfill' }; export type CrawlerWorker = { state: 'idle' } | { state: 'working'; chapter_id: string }; export type CrawlerLastPass = { at: string | null; discovered: number; upserted: number; covers_fetched: number; mangas_failed: number; }; export type CrawlerStatus = { daemon: 'running' | 'disabled'; phase: CrawlerPhase | null; workers: CrawlerWorker[]; last_pass: CrawlerLastPass; session: { expired: boolean; configured: boolean }; browser: 'healthy' | 'draining' | 'restarting' | 'down'; queue: { pending: number; running: number; dead: number }; }; export async function getCrawlerStatus(): Promise { return request('/v1/admin/crawler'); } /** POST /v1/admin/crawler/run — trigger an out-of-cycle metadata pass. */ export async function runCrawlerPass(): Promise<{ started: boolean }> { return request('/v1/admin/crawler/run', { method: 'POST' }); } /** POST /v1/admin/crawler/browser/restart — coordinated Chromium restart. */ export async function restartCrawlerBrowser(): Promise<{ ok: boolean; error: string | null }> { return request('/v1/admin/crawler/browser/restart', { method: 'POST' }); } /** POST /v1/admin/crawler/session — refresh PHPSESSID and re-probe. */ export async function updateCrawlerSession( phpsessid: string ): Promise<{ valid: boolean; error: string | null }> { return request('/v1/admin/crawler/session', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ phpsessid }) }); } /** POST /v1/admin/crawler/session/clear-expired — resume idled workers. */ export async function clearCrawlerSessionExpired(): Promise<{ cleared: boolean }> { return request('/v1/admin/crawler/session/clear-expired', { method: 'POST' }); } export type DeadJob = { id: string; kind: string; chapter_id: string | null; manga_id: string | null; manga_title: string | null; chapter_number: number | null; attempts: number; max_attempts: number; last_error: string | null; updated_at: string; }; export type DeadJobsPage = { items: DeadJob[]; page: Page }; export async function listDeadJobs(opts?: { search?: string; limit?: number; offset?: number; }): Promise { const params = new URLSearchParams(); if (opts?.search) params.set('search', opts.search); if (opts?.limit != null) params.set('limit', String(opts.limit)); if (opts?.offset != null) params.set('offset', String(opts.offset)); const qs = params.toString(); return request(`/v1/admin/crawler/dead-jobs${qs ? `?${qs}` : ''}`); } /** Requeue scope: all dead jobs, one manga's, one chapter's, or a single job. */ export type RequeueScope = | { scope: 'all' } | { scope: 'manga'; manga_id: string } | { scope: 'chapter'; chapter_id: string } | { scope: 'job'; job_id: string }; export async function requeueDeadJobs(scope: RequeueScope): Promise<{ requeued: number }> { return request('/v1/admin/crawler/dead-jobs/requeue', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(scope) }); }