export type LibraryDto = { id: string; name: string; root_path: string; enabled: boolean; book_count: number; monitor_enabled: boolean; scan_mode: string; next_scan_at: string | null; watcher_enabled: boolean; metadata_provider: string | null; fallback_metadata_provider: string | null; metadata_refresh_mode: string; next_metadata_refresh_at: string | null; series_count: number; thumbnail_book_ids: string[]; }; export type IndexJobDto = { id: string; library_id: string | null; book_id: string | null; type: string; status: string; started_at: string | null; finished_at: string | null; error_opt: string | null; created_at: string; stats_json: { scanned_files: number; indexed_files: number; removed_files: number; errors: number; warnings: number; } | null; progress_percent: number | null; processed_files: number | null; total_files: number | null; }; export type TokenDto = { id: string; name: string; scope: string; prefix: string; revoked_at: string | null; }; export type FolderItem = { name: string; path: string; depth: number; has_children: boolean; }; export type ReadingStatus = "unread" | "reading" | "read"; export type ReadingProgressDto = { status: ReadingStatus; current_page: number | null; last_read_at: string | null; }; export type BookDto = { id: string; library_id: string; kind: string; format: string | null; title: string; author: string | null; authors: string[]; series: string | null; volume: number | null; language: string | null; page_count: number | null; file_path: string | null; file_format: string | null; file_parse_status: string | null; updated_at: string; reading_status: ReadingStatus; reading_current_page: number | null; reading_last_read_at: string | null; summary: string | null; isbn: string | null; publish_date: string | null; locked_fields?: Record; }; export type BooksPageDto = { items: BookDto[]; total: number; page: number; limit: number; }; export type SearchHitDto = { id: string; library_id: string; title: string; authors: string[]; series: string | null; volume: number | null; kind: string; language: string | null; }; export type SeriesHitDto = { library_id: string; name: string; book_count: number; books_read_count: number; first_book_id: string; }; export type SearchResponseDto = { hits: SearchHitDto[]; series_hits: SeriesHitDto[]; estimated_total_hits: number | null; processing_time_ms: number | null; }; export type SeriesDto = { name: string; book_count: number; books_read_count: number; first_book_id: string; library_id: string; series_status: string | null; missing_count: number | null; metadata_provider: string | null; }; export function config() { const baseUrl = process.env.API_BASE_URL || "http://api:7080"; const token = process.env.API_BOOTSTRAP_TOKEN; if (!token) { throw new Error("API_BOOTSTRAP_TOKEN is required for backoffice"); } return { baseUrl: baseUrl.replace(/\/$/, ""), token }; } export async function apiFetch( path: string, init?: RequestInit & { next?: { revalidate?: number; tags?: string[] } }, ): Promise { const { baseUrl, token } = config(); const headers = new Headers(init?.headers || {}); headers.set("Authorization", `Bearer ${token}`); if (init?.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } const { next: nextOptions, ...restInit } = init ?? {}; const res = await fetch(`${baseUrl}${path}`, { ...restInit, headers, ...(nextOptions ? { next: nextOptions } : { cache: "no-store" as const }), }); if (!res.ok) { const text = await res.text(); throw new Error(`API ${path} failed (${res.status}): ${text}`); } if (res.status === 204) { return null as T; } return (await res.json()) as T; } export async function fetchLibraries() { return apiFetch("/libraries", { next: { revalidate: 30 } }); } export async function createLibrary(name: string, rootPath: string) { return apiFetch("/libraries", { method: "POST", body: JSON.stringify({ name, root_path: rootPath }), }); } export async function deleteLibrary(id: string) { return apiFetch(`/libraries/${id}`, { method: "DELETE" }); } export async function scanLibrary(libraryId: string, full?: boolean) { const body: { full?: boolean } = {}; if (full) body.full = true; return apiFetch(`/libraries/${libraryId}/scan`, { method: "POST", body: JSON.stringify(body), }); } export async function updateLibraryMonitoring( libraryId: string, monitorEnabled: boolean, scanMode: string, watcherEnabled?: boolean, metadataRefreshMode?: string, ) { const body: { monitor_enabled: boolean; scan_mode: string; watcher_enabled?: boolean; metadata_refresh_mode?: string; } = { monitor_enabled: monitorEnabled, scan_mode: scanMode, }; if (watcherEnabled !== undefined) { body.watcher_enabled = watcherEnabled; } if (metadataRefreshMode !== undefined) { body.metadata_refresh_mode = metadataRefreshMode; } return apiFetch(`/libraries/${libraryId}/monitoring`, { method: "PATCH", body: JSON.stringify(body), }); } export async function listJobs() { return apiFetch("/index/status"); } export async function rebuildIndex(libraryId?: string, full?: boolean, rescan?: boolean) { const body: { library_id?: string; full?: boolean; rescan?: boolean } = {}; if (libraryId) body.library_id = libraryId; if (full) body.full = true; if (rescan) body.rescan = true; return apiFetch("/index/rebuild", { method: "POST", body: JSON.stringify(body), }); } export async function rebuildThumbnails(libraryId?: string) { const body: { library_id?: string } = {}; if (libraryId) body.library_id = libraryId; return apiFetch("/index/thumbnails/rebuild", { method: "POST", body: JSON.stringify(body), }); } export async function regenerateThumbnails(libraryId?: string) { const body: { library_id?: string } = {}; if (libraryId) body.library_id = libraryId; return apiFetch("/index/thumbnails/regenerate", { method: "POST", body: JSON.stringify(body), }); } export async function cancelJob(id: string) { return apiFetch(`/index/cancel/${id}`, { method: "POST" }); } export async function listFolders(path?: string) { const url = path ? `/folders?path=${encodeURIComponent(path)}` : "/folders"; return apiFetch(url); } export async function listTokens() { return apiFetch("/admin/tokens"); } export async function createToken(name: string, scope: string) { return apiFetch<{ token: string }>("/admin/tokens", { method: "POST", body: JSON.stringify({ name, scope }), }); } export async function revokeToken(id: string) { return apiFetch(`/admin/tokens/${id}`, { method: "DELETE" }); } export async function deleteToken(id: string) { return apiFetch(`/admin/tokens/${id}/delete`, { method: "POST" }); } export async function fetchBooks( libraryId?: string, series?: string, page: number = 1, limit: number = 50, readingStatus?: string, sort?: string, author?: string, format?: string, metadataProvider?: string, ): Promise { const params = new URLSearchParams(); if (libraryId) params.set("library_id", libraryId); if (series) params.set("series", series); if (readingStatus) params.set("reading_status", readingStatus); if (sort) params.set("sort", sort); if (author) params.set("author", author); if (format) params.set("format", format); if (metadataProvider) params.set("metadata_provider", metadataProvider); params.set("page", page.toString()); params.set("limit", limit.toString()); return apiFetch(`/books?${params.toString()}`); } export type SeriesPageDto = { items: SeriesDto[]; total: number; page: number; limit: number; }; export async function fetchSeries( libraryId: string, page: number = 1, limit: number = 50, seriesStatus?: string, hasMissing?: boolean, ): Promise { const params = new URLSearchParams(); params.set("page", page.toString()); params.set("limit", limit.toString()); if (seriesStatus) params.set("series_status", seriesStatus); if (hasMissing) params.set("has_missing", "true"); return apiFetch( `/libraries/${libraryId}/series?${params.toString()}`, ); } export async function fetchAllSeries( libraryId?: string, q?: string, readingStatus?: string, page: number = 1, limit: number = 50, sort?: string, seriesStatus?: string, hasMissing?: boolean, metadataProvider?: string, author?: string, ): Promise { const params = new URLSearchParams(); if (libraryId) params.set("library_id", libraryId); if (q) params.set("q", q); if (readingStatus) params.set("reading_status", readingStatus); if (sort) params.set("sort", sort); if (seriesStatus) params.set("series_status", seriesStatus); if (hasMissing) params.set("has_missing", "true"); if (metadataProvider) params.set("metadata_provider", metadataProvider); if (author) params.set("author", author); params.set("page", page.toString()); params.set("limit", limit.toString()); return apiFetch(`/series?${params.toString()}`); } export async function fetchSeriesStatuses(): Promise { return apiFetch("/series/statuses", { next: { revalidate: 300 } }); } export async function searchBooks( query: string, libraryId?: string, limit: number = 20, ): Promise { const params = new URLSearchParams(); params.set("q", query); if (libraryId) params.set("library_id", libraryId); params.set("limit", limit.toString()); return apiFetch(`/search?${params.toString()}`); } export function getBookCoverUrl(bookId: string): string { return `/api/books/${bookId}/thumbnail`; } export type Settings = { image_processing: { format: string; quality: number; filter: string; max_width: number; }; cache: { enabled: boolean; directory: string; max_size_mb: number; }; limits: { concurrent_renders: number; timeout_seconds: number; rate_limit_per_second: number; }; thumbnail: { enabled: boolean; width: number; height: number; quality: number; format: string; directory: string; }; }; export type CacheStats = { total_size_mb: number; file_count: number; directory: string; }; export type ClearCacheResponse = { success: boolean; message: string; }; export type ThumbnailStats = { total_size_mb: number; file_count: number; directory: string; }; export async function getSettings() { return apiFetch("/settings", { next: { revalidate: 60 } }); } export async function updateSetting(key: string, value: unknown) { return apiFetch(`/settings/${key}`, { method: "POST", body: JSON.stringify({ value }), }); } export async function getCacheStats() { return apiFetch("/settings/cache/stats", { next: { revalidate: 30 } }); } export async function clearCache() { return apiFetch("/settings/cache/clear", { method: "POST", }); } export async function getThumbnailStats() { return apiFetch("/settings/thumbnail/stats", { next: { revalidate: 30 } }); } // Status mappings export type StatusMappingDto = { id: string; provider_status: string; mapped_status: string | null; }; export async function fetchStatusMappings(): Promise { return apiFetch("/settings/status-mappings", { next: { revalidate: 60 } }); } export async function upsertStatusMapping(provider_status: string, mapped_status: string): Promise { return apiFetch("/settings/status-mappings", { method: "POST", body: JSON.stringify({ provider_status, mapped_status }), }); } export async function deleteStatusMapping(id: string): Promise { await apiFetch(`/settings/status-mappings/${id}`, { method: "DELETE" }); } export async function convertBook(bookId: string) { return apiFetch(`/books/${bookId}/convert`, { method: "POST" }); } export async function fetchReadingProgress(bookId: string) { return apiFetch(`/books/${bookId}/progress`); } export async function updateReadingProgress( bookId: string, status: ReadingStatus, currentPage?: number, ) { return apiFetch(`/books/${bookId}/progress`, { method: "PATCH", body: JSON.stringify({ status, current_page: currentPage ?? null }), }); } export type StatsOverview = { total_books: number; total_series: number; total_libraries: number; total_pages: number; total_size_bytes: number; total_authors: number; }; export type ReadingStatusStats = { unread: number; reading: number; read: number; }; export type FormatCount = { format: string; count: number; }; export type LanguageCount = { language: string | null; count: number; }; export type LibraryStatsItem = { library_name: string; book_count: number; size_bytes: number; read_count: number; reading_count: number; unread_count: number; }; export type TopSeriesItem = { series: string; book_count: number; read_count: number; total_pages: number; }; export type MonthlyAdditions = { month: string; books_added: number; }; export type ProviderCount = { provider: string; count: number; }; export type MetadataStats = { total_series: number; series_linked: number; series_unlinked: number; books_with_summary: number; books_with_isbn: number; by_provider: ProviderCount[]; }; export type CurrentlyReadingItem = { book_id: string; title: string; series: string | null; current_page: number; page_count: number; }; export type RecentlyReadItem = { book_id: string; title: string; series: string | null; last_read_at: string; }; export type MonthlyReading = { month: string; books_read: number; }; export type JobTimePoint = { label: string; scan: number; rebuild: number; thumbnail: number; other: number; }; export type StatsResponse = { overview: StatsOverview; reading_status: ReadingStatusStats; currently_reading: CurrentlyReadingItem[]; recently_read: RecentlyReadItem[]; reading_over_time: MonthlyReading[]; by_format: FormatCount[]; by_language: LanguageCount[]; by_library: LibraryStatsItem[]; top_series: TopSeriesItem[]; additions_over_time: MonthlyAdditions[]; jobs_over_time: JobTimePoint[]; metadata: MetadataStats; }; export async function fetchStats(period?: "day" | "week" | "month") { const params = period && period !== "month" ? `?period=${period}` : ""; return apiFetch(`/stats${params}`, { next: { revalidate: 30 } }); } // --------------------------------------------------------------------------- // Authors // --------------------------------------------------------------------------- export type AuthorDto = { name: string; book_count: number; series_count: number; }; export type AuthorsPageDto = { items: AuthorDto[]; total: number; page: number; limit: number; }; export async function fetchAuthors( q?: string, page: number = 1, limit: number = 20, sort?: string, ): Promise { const params = new URLSearchParams(); if (q) params.set("q", q); if (sort) params.set("sort", sort); params.set("page", page.toString()); params.set("limit", limit.toString()); return apiFetch(`/authors?${params.toString()}`); } export type UpdateBookRequest = { title: string; author: string | null; authors: string[]; series: string | null; volume: number | null; language: string | null; summary: string | null; isbn: string | null; publish_date: string | null; locked_fields?: Record; }; export async function updateBook(bookId: string, data: UpdateBookRequest) { return apiFetch(`/books/${bookId}`, { method: "PATCH", body: JSON.stringify(data), }); } export type SeriesMetadataDto = { authors: string[]; description: string | null; publishers: string[]; start_year: number | null; total_volumes: number | null; status: string | null; book_author: string | null; book_language: string | null; locked_fields: Record; }; export async function fetchSeriesMetadata(libraryId: string, seriesName: string) { return apiFetch( `/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}/metadata` ); } export type UpdateSeriesRequest = { new_name: string; authors: string[]; author?: string | null; language?: string | null; description: string | null; publishers: string[]; start_year: number | null; total_volumes: number | null; locked_fields?: Record; }; export async function updateSeries(libraryId: string, seriesName: string, data: UpdateSeriesRequest) { return apiFetch<{ updated: number }>(`/libraries/${libraryId}/series/${encodeURIComponent(seriesName)}`, { method: "PATCH", body: JSON.stringify(data), }); } export async function markSeriesRead(seriesName: string, status: "read" | "unread" = "read") { return apiFetch<{ updated: number }>("/series/mark-read", { method: "POST", body: JSON.stringify({ series: seriesName, status }), }); } export type KomgaSyncRequest = { url: string; username: string; password: string; }; export type KomgaSyncResponse = { id: string; komga_url: string; total_komga_read: number; matched: number; already_read: number; newly_marked: number; matched_books: string[]; newly_marked_books: string[]; unmatched: string[]; created_at: string; }; export type KomgaSyncReportSummary = { id: string; komga_url: string; total_komga_read: number; matched: number; already_read: number; newly_marked: number; unmatched_count: number; created_at: string; }; export async function syncKomga(req: KomgaSyncRequest) { return apiFetch("/komga/sync", { method: "POST", body: JSON.stringify(req), }); } export async function listKomgaReports() { return apiFetch("/komga/reports"); } export async function getKomgaReport(id: string) { return apiFetch(`/komga/reports/${id}`); } // --------------------------------------------------------------------------- // External Metadata // --------------------------------------------------------------------------- export type SeriesCandidateDto = { provider: string; external_id: string; title: string; authors: string[]; description: string | null; publishers: string[]; start_year: number | null; total_volumes: number | null; cover_url: string | null; external_url: string | null; confidence: number; metadata_json: Record; }; export type ExternalMetadataLinkDto = { id: string; library_id: string; series_name: string; provider: string; external_id: string; external_url: string | null; status: string; confidence: number | null; metadata_json: Record; total_volumes_external: number | null; matched_at: string; approved_at: string | null; synced_at: string | null; }; export type FieldChange = { field: string; old_value?: unknown; new_value?: unknown; }; export type SeriesSyncReport = { fields_updated: FieldChange[]; fields_skipped: FieldChange[]; }; export type BookSyncReport = { book_id: string; title: string; volume: number | null; fields_updated: FieldChange[]; fields_skipped: FieldChange[]; }; export type SyncReport = { series: SeriesSyncReport | null; books: BookSyncReport[]; books_matched: number; books_unmatched: number; books_message?: string; }; export type MissingBooksDto = { total_external: number; total_local: number; missing_count: number; missing_books: { title: string | null; volume_number: number | null; external_book_id: string | null; }[]; }; export async function searchMetadata(libraryId: string, seriesName: string, provider?: string) { return apiFetch("/metadata/search", { method: "POST", body: JSON.stringify({ library_id: libraryId, series_name: seriesName, provider: provider || undefined }), }); } export async function createMetadataMatch(data: { library_id: string; series_name: string; provider: string; external_id: string; external_url?: string | null; confidence?: number | null; title: string; metadata_json: Record; total_volumes?: number | null; }) { return apiFetch("/metadata/match", { method: "POST", body: JSON.stringify(data), }); } export async function approveMetadataMatch(id: string, syncSeries: boolean, syncBooks: boolean) { return apiFetch<{ status: string; report: SyncReport }>(`/metadata/approve/${id}`, { method: "POST", body: JSON.stringify({ sync_series: syncSeries, sync_books: syncBooks }), }); } export async function rejectMetadataMatch(id: string) { return apiFetch<{ status: string }>(`/metadata/reject/${id}`, { method: "POST", }); } export async function getMetadataLink(libraryId: string, seriesName: string) { const params = new URLSearchParams(); params.set("library_id", libraryId); params.set("series_name", seriesName); return apiFetch(`/metadata/links?${params.toString()}`); } export async function getMissingBooks(linkId: string) { return apiFetch(`/metadata/missing/${linkId}`); } export async function deleteMetadataLink(id: string) { return apiFetch<{ deleted: boolean }>(`/metadata/links/${id}`, { method: "DELETE", }); } export async function updateLibraryMetadataProvider(libraryId: string, provider: string | null, fallbackProvider?: string | null) { return apiFetch(`/libraries/${libraryId}/metadata-provider`, { method: "PATCH", body: JSON.stringify({ metadata_provider: provider, fallback_metadata_provider: fallbackProvider }), }); } // --------------------------------------------------------------------------- // Batch Metadata // --------------------------------------------------------------------------- export type MetadataBatchReportDto = { job_id: string; status: string; total_series: number; processed: number; auto_matched: number; no_results: number; too_many_results: number; low_confidence: number; already_linked: number; errors: number; }; export type MetadataBatchResultDto = { id: string; series_name: string; status: string; provider_used: string | null; fallback_used: boolean; candidates_count: number; best_confidence: number | null; best_candidate_json: Record | null; link_id: string | null; error_message: string | null; }; export async function startMetadataBatch(libraryId: string) { return apiFetch<{ id: string; status: string }>("/metadata/batch", { method: "POST", body: JSON.stringify({ library_id: libraryId }), }); } export async function startMetadataRefresh(libraryId: string) { return apiFetch<{ id: string; status: string }>("/metadata/refresh", { method: "POST", body: JSON.stringify({ library_id: libraryId }), }); } export type RefreshFieldDiff = { field: string; old?: unknown; new?: unknown; }; export type RefreshBookDiff = { book_id: string; title: string; volume: number | null; changes: RefreshFieldDiff[]; }; export type RefreshSeriesResult = { series_name: string; provider: string; status: string; // "updated" | "unchanged" | "error" series_changes: RefreshFieldDiff[]; book_changes: RefreshBookDiff[]; error?: string; }; export type MetadataRefreshReportDto = { job_id: string; status: string; total_links: number; refreshed: number; unchanged: number; errors: number; changes: RefreshSeriesResult[]; }; export async function getMetadataRefreshReport(jobId: string) { return apiFetch(`/metadata/refresh/${jobId}/report`); } export async function getMetadataBatchReport(jobId: string) { return apiFetch(`/metadata/batch/${jobId}/report`); } export async function getMetadataBatchResults(jobId: string, status?: string) { const params = status ? `?status=${status}` : ""; return apiFetch(`/metadata/batch/${jobId}/results${params}`); } // --------------------------------------------------------------------------- // Prowlarr // --------------------------------------------------------------------------- export type ProwlarrCategory = { id: number; name: string | null; }; export type ProwlarrRelease = { guid: string; title: string; size: number; downloadUrl: string | null; indexer: string | null; seeders: number | null; leechers: number | null; publishDate: string | null; protocol: string | null; infoUrl: string | null; categories: ProwlarrCategory[] | null; matchedMissingVolumes: number[] | null; }; export type ProwlarrSearchResponse = { results: ProwlarrRelease[]; query: string; }; export type ProwlarrTestResponse = { success: boolean; message: string; indexer_count: number | null; }; // --------------------------------------------------------------------------- // qBittorrent // --------------------------------------------------------------------------- export type QBittorrentAddResponse = { success: boolean; message: string; }; export type QBittorrentTestResponse = { success: boolean; message: string; version: string | null; };