// 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, apiUrl, type Page } from './client'; import type { User } from './auth'; import type { MangaDetail } from './mangas'; import type { Chapter } from './chapters'; import type { ContentWarning } from './page_tags'; // ---- 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'); } // ---- storage usage --------------------------------------------------------- export type TopManga = { id: string; title: string; total_bytes: number; }; export type TopChapter = { id: string; manga_id: string; number: number; title: string | null; total_bytes: number; }; export type StorageStats = { total_bytes: number; covers_bytes: number; chapters_bytes: number; disk_total_bytes: number | null; ratio_of_disk: number | null; avg_image_bytes: number | null; avg_cover_bytes: number | null; avg_chapter_page_bytes: number | null; top_mangas: TopManga[]; top_chapters: TopChapter[]; /** Pages still awaiting a size backfill. When > 0 the figures above are * partial and the leaderboards omit not-yet-measured content. */ unmeasured_pages: number; /** Cover blobs still awaiting a size backfill. */ unmeasured_covers: number; }; export type StorageBackfillResult = { pages: number; covers: number; /** Blobs storage reports as absent (orphaned). */ missing: number; /** Blobs that errored for another reason (bad key, transient IO) — retryable. */ errored: number; /** `true` when a per-run cap was hit before the backlog drained; run again. */ more_remaining: boolean; }; export async function getStorageStats(): Promise { return request('/v1/admin/storage'); } export async function backfillStorage(): Promise { return request('/v1/admin/storage/backfill', { method: 'POST' }); } // ---- 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'; index: number; total: number }; /** A chapter being crawled right now, with a live page count. */ export type ActiveChapter = { manga_id: string; manga_title: string; chapter_id: string; chapter_number: number; pages_done: number; pages_total: number | null; }; 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; worker_count: number; active_chapters: ActiveChapter[]; current_cover: { manga_id: string; manga_title: string } | null; covers_queued: number; 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'); } /** URL of the Server-Sent Events live-status stream. Open with * `new EventSource(...)` while the crawler page is mounted and close it on * navigate-away so the subscription is scoped to the active page. Each * message is a named `status` event whose `data` is a {@link CrawlerStatus}. */ export function crawlerStatusStreamUrl(): string { return apiUrl('/v1/admin/crawler/stream'); } /** 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; }, init?: RequestInit ): 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}` : ''}`, init ); } /** Requeue scope: all dead jobs, one manga's, one chapter's, or a single job. * `scope: 'all'` requires an explicit `confirm: true` so a careless * click (or CSRF bait) can't flip the entire dead pile. */ export type RequeueScope = | { scope: 'all'; confirm: true } | { 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) }); } /** A queued/running chapter-content job (which chapters are queued). */ export type ActiveJob = { id: string; chapter_id: string | null; manga_id: string | null; manga_title: string | null; chapter_number: number | null; state: 'pending' | 'running'; attempts: number; max_attempts: number; updated_at: string; }; export type ActiveJobsPage = { items: ActiveJob[]; page: Page }; /** GET /v1/admin/crawler/active-jobs — which chapters of which mangas are * queued or running now. */ export async function listActiveJobs( opts?: { search?: string; limit?: number; offset?: number; }, init?: RequestInit ): 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/active-jobs${qs ? `?${qs}` : ''}`, init ); } /** A manga queued for a cover fetch (no cover yet + a live source). */ export type MissingCover = { manga_id: string; manga_title: string }; export type MissingCoversPage = { items: MissingCover[]; page: Page }; /** GET /v1/admin/crawler/covers — which manga covers are queued. */ export async function listMissingCovers( opts?: { search?: string; limit?: number; offset?: number; }, init?: RequestInit ): 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/covers${qs ? `?${qs}` : ''}`, init ); } // ---- AI content analysis --------------------------------------------------- /** Options for the scoped analysis re-enqueue. `mangaId` and `chapterId` * are mutually exclusive; omit both to target the whole library. */ export type ReenqueueAnalysisOptions = { /** Skip pages that already have a completed analysis (default true). * When false, in-scope pages are force re-analyzed. */ onlyUnanalyzed?: boolean; mangaId?: string; chapterId?: string; }; /** Bulk-enqueue `analyze_page` jobs for all / a manga's / a chapter's * pages. Returns how many jobs were enqueued. Requires ANALYSIS_ENABLED * on the backend (503 otherwise). */ export async function reenqueueAnalysis( opts: ReenqueueAnalysisOptions = {} ): Promise<{ enqueued: number }> { const body: Record = { only_unanalyzed: opts.onlyUnanalyzed ?? true }; if (opts.mangaId) body.manga_id = opts.mangaId; if (opts.chapterId) body.chapter_id = opts.chapterId; return request<{ enqueued: number }>('/v1/admin/analysis/reenqueue', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); } /** Force re-analysis of a single page (used by the reader context menu). */ export async function analyzePage(pageId: string): Promise<{ enqueued: boolean }> { return request<{ enqueued: boolean }>( `/v1/admin/pages/${encodeURIComponent(pageId)}/analyze`, { method: 'POST' } ); } // ---- AI content analysis: coverage & inspection ---------------------------- export type MangaCoverage = { manga_id: string; title: string; total_pages: number; analyzed_pages: number; }; export type MangaCoveragePage = { items: MangaCoverage[]; page: Page; }; export type ChapterCoverage = { chapter_id: string; number: number; title: string | null; total_pages: number; analyzed_pages: number; }; /** Per-page status in a chapter grid. */ export type PageAnalysisStatus = 'done' | 'failed' | 'queued' | 'none'; export type PageStatusItem = { page_id: string; page_number: number; status: PageAnalysisStatus; }; export type OcrLine = { kind: string; text: string }; export type PageAnalysisDetail = { page_id: string; page_number: number; chapter_id: string; manga_id: string; /** `done` | `failed` | `none` (no analysis row yet). */ status: 'done' | 'failed' | 'none'; is_nsfw: boolean; scene_description: string | null; model: string | null; error: string | null; analyzed_at: string | null; ocr: OcrLine[]; tags: string[]; content_warnings: ContentWarning[]; }; /** Paginated per-manga analysis coverage (admin overview). */ export async function getAnalysisMangaCoverage( 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/analysis/mangas${qs ? `?${qs}` : ''}` ); } export async function getAnalysisChapterCoverage( mangaId: string ): Promise { const r = await request<{ items: ChapterCoverage[] }>( `/v1/admin/analysis/mangas/${encodeURIComponent(mangaId)}/chapters` ); return r.items; } export async function getAnalysisChapterPages( chapterId: string ): Promise { const r = await request<{ items: PageStatusItem[] }>( `/v1/admin/analysis/chapters/${encodeURIComponent(chapterId)}/pages` ); return r.items; } export async function getAnalysisPageDetail( pageId: string ): Promise { return request( `/v1/admin/analysis/pages/${encodeURIComponent(pageId)}` ); } /** One live analysis event from the SSE stream. */ export type AnalysisEvent = | { kind: 'enqueued'; count: number; manga_id: string | null; chapter_id: string | null; } | { kind: 'started' | 'completed' | 'failed'; page_id: string; manga_id: string; chapter_id: string; page_number: number; }; /** URL of the live analysis SSE stream. Open with `new EventSource(...)` * while the admin Analysis page is mounted and close it on navigate-away. * Each message is a named `analysis` event whose `data` is an * {@link AnalysisEvent}; a `lagged` event signals dropped frames. */ export function analysisStatusStreamUrl(): string { return apiUrl('/v1/admin/analysis/status/stream'); } // ---- runtime settings (crawler + analysis) --------------------------------- /** Operationally-safe, admin-editable crawler settings. Host/infra and * session/secret fields are managed via environment and surfaced read-only * in {@link CrawlerEnvOnly}. */ export type CrawlerSettings = { daemon_enabled: boolean; daily_at: string; // "HH:MM" tz: string; // IANA idle_timeout_secs: number; chapter_workers: number; retention_days: number; start_url: string | null; rate_ms: number; cdn_host: string | null; cdn_rate_ms: number; cookie_domain: string | null; user_agent: string | null; download_allowlist: string[]; allow_any_host: boolean; max_image_bytes: number; manga_limit: number; job_timeout_secs: number; metadata_max_consecutive_failures: number; browser_restart_threshold: number; }; /** Read-only view of crawler fields managed via environment. */ export type CrawlerEnvOnly = { browser_mode: string; browser_args: string[]; proxy: string | null; tor_control_url: string | null; tor_credentials_configured: boolean; session_configured: boolean; }; export type CrawlerSettingsResponse = { editable: CrawlerSettings; env_only: CrawlerEnvOnly; }; /** Admin-editable analysis settings. Prompts are `null` to mean "use the * compiled default" (see {@link PromptDefaults}). The vision API key is * env-only and never returned. */ export type AnalysisSettings = { enabled: boolean; workers: number; endpoint: string; model: string; request_timeout_secs: number; job_timeout_secs: number; max_tokens: number; max_pixels: number; min_slice_height: number; slice_overlap: number; tall_aspect_threshold: number; max_slices: number; max_image_bytes: number; response_format: 'json_schema' | 'json_object' | 'none'; frequency_penalty: number; temperature: number; system_prompt: string | null; ocr_prompt: string | null; grounding_prompt: string | null; }; export type PromptDefaults = { system_prompt: string; ocr_prompt: string; grounding_prompt: string; }; export type AnalysisSettingsResponse = { editable: AnalysisSettings; env_only: { api_key_configured: boolean }; prompt_defaults: PromptDefaults; }; export async function getCrawlerSettings(): Promise { return request('/v1/admin/settings/crawler'); } export async function updateCrawlerSettings( settings: CrawlerSettings ): Promise { return request('/v1/admin/settings/crawler', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(settings) }); } export async function getAnalysisSettings(): Promise { return request('/v1/admin/settings/analysis'); } export async function updateAnalysisSettings( settings: AnalysisSettings ): Promise { return request('/v1/admin/settings/analysis', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(settings) }); }