Files
stripstream-librarian/apps/backoffice/lib/api.ts
Froidefond Julien cc65e3d1ad feat: highlight missing volumes in Prowlarr search results
API extracts volume numbers from release titles and matches them against
missing volumes sent by the frontend. Matched results are highlighted in
green with badges indicating which missing volumes were found.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-20 12:44:35 +01:00

970 lines
24 KiB
TypeScript

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;
};
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<string, boolean>;
};
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<T>(
path: string,
init?: RequestInit,
): Promise<T> {
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 res = await fetch(`${baseUrl}${path}`, {
...init,
headers,
cache: "no-store",
});
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<LibraryDto[]>("/libraries");
}
export async function createLibrary(name: string, rootPath: string) {
return apiFetch<LibraryDto>("/libraries", {
method: "POST",
body: JSON.stringify({ name, root_path: rootPath }),
});
}
export async function deleteLibrary(id: string) {
return apiFetch<void>(`/libraries/${id}`, { method: "DELETE" });
}
export async function scanLibrary(libraryId: string, full?: boolean) {
const body: { full?: boolean } = {};
if (full) body.full = true;
return apiFetch<IndexJobDto>(`/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<LibraryDto>(`/libraries/${libraryId}/monitoring`, {
method: "PATCH",
body: JSON.stringify(body),
});
}
export async function listJobs() {
return apiFetch<IndexJobDto[]>("/index/status");
}
export async function rebuildIndex(libraryId?: string, full?: boolean) {
const body: { library_id?: string; full?: boolean } = {};
if (libraryId) body.library_id = libraryId;
if (full) body.full = true;
return apiFetch<IndexJobDto>("/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<IndexJobDto>("/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<IndexJobDto>("/index/thumbnails/regenerate", {
method: "POST",
body: JSON.stringify(body),
});
}
export async function cancelJob(id: string) {
return apiFetch<IndexJobDto>(`/index/cancel/${id}`, { method: "POST" });
}
export async function listFolders(path?: string) {
const url = path ? `/folders?path=${encodeURIComponent(path)}` : "/folders";
return apiFetch<FolderItem[]>(url);
}
export async function listTokens() {
return apiFetch<TokenDto[]>("/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<void>(`/admin/tokens/${id}`, { method: "DELETE" });
}
export async function deleteToken(id: string) {
return apiFetch<void>(`/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,
): Promise<BooksPageDto> {
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);
params.set("page", page.toString());
params.set("limit", limit.toString());
return apiFetch<BooksPageDto>(`/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<SeriesPageDto> {
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<SeriesPageDto>(
`/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,
): Promise<SeriesPageDto> {
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);
params.set("page", page.toString());
params.set("limit", limit.toString());
return apiFetch<SeriesPageDto>(`/series?${params.toString()}`);
}
export async function fetchSeriesStatuses(): Promise<string[]> {
return apiFetch<string[]>("/series/statuses");
}
export async function searchBooks(
query: string,
libraryId?: string,
limit: number = 20,
): Promise<SearchResponseDto> {
const params = new URLSearchParams();
params.set("q", query);
if (libraryId) params.set("library_id", libraryId);
params.set("limit", limit.toString());
return apiFetch<SearchResponseDto>(`/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>("/settings");
}
export async function updateSetting(key: string, value: unknown) {
return apiFetch<unknown>(`/settings/${key}`, {
method: "POST",
body: JSON.stringify({ value }),
});
}
export async function getCacheStats() {
return apiFetch<CacheStats>("/settings/cache/stats");
}
export async function clearCache() {
return apiFetch<ClearCacheResponse>("/settings/cache/clear", {
method: "POST",
});
}
export async function getThumbnailStats() {
return apiFetch<ThumbnailStats>("/settings/thumbnail/stats");
}
// Status mappings
export type StatusMappingDto = {
id: string;
provider_status: string;
mapped_status: string | null;
};
export async function fetchStatusMappings(): Promise<StatusMappingDto[]> {
return apiFetch<StatusMappingDto[]>("/settings/status-mappings");
}
export async function upsertStatusMapping(provider_status: string, mapped_status: string): Promise<StatusMappingDto> {
return apiFetch<StatusMappingDto>("/settings/status-mappings", {
method: "POST",
body: JSON.stringify({ provider_status, mapped_status }),
});
}
export async function deleteStatusMapping(id: string): Promise<void> {
await apiFetch<unknown>(`/settings/status-mappings/${id}`, { method: "DELETE" });
}
export async function convertBook(bookId: string) {
return apiFetch<IndexJobDto>(`/books/${bookId}/convert`, { method: "POST" });
}
export async function fetchReadingProgress(bookId: string) {
return apiFetch<ReadingProgressDto>(`/books/${bookId}/progress`);
}
export async function updateReadingProgress(
bookId: string,
status: ReadingStatus,
currentPage?: number,
) {
return apiFetch<ReadingProgressDto>(`/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 StatsResponse = {
overview: StatsOverview;
reading_status: ReadingStatusStats;
by_format: FormatCount[];
by_language: LanguageCount[];
by_library: LibraryStatsItem[];
top_series: TopSeriesItem[];
additions_over_time: MonthlyAdditions[];
metadata: MetadataStats;
};
export async function fetchStats() {
return apiFetch<StatsResponse>("/stats");
}
// ---------------------------------------------------------------------------
// 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<AuthorsPageDto> {
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<AuthorsPageDto>(`/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<string, boolean>;
};
export async function updateBook(bookId: string, data: UpdateBookRequest) {
return apiFetch<BookDto>(`/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<string, boolean>;
};
export async function fetchSeriesMetadata(libraryId: string, seriesName: string) {
return apiFetch<SeriesMetadataDto>(
`/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<string, boolean>;
};
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<KomgaSyncResponse>("/komga/sync", {
method: "POST",
body: JSON.stringify(req),
});
}
export async function listKomgaReports() {
return apiFetch<KomgaSyncReportSummary[]>("/komga/reports");
}
export async function getKomgaReport(id: string) {
return apiFetch<KomgaSyncResponse>(`/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<string, unknown>;
};
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<string, unknown>;
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<SeriesCandidateDto[]>("/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<string, unknown>;
total_volumes?: number | null;
}) {
return apiFetch<ExternalMetadataLinkDto>("/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<ExternalMetadataLinkDto[]>(`/metadata/links?${params.toString()}`);
}
export async function getMissingBooks(linkId: string) {
return apiFetch<MissingBooksDto>(`/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<LibraryDto>(`/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<string, unknown> | 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<MetadataRefreshReportDto>(`/metadata/refresh/${jobId}/report`);
}
export async function getMetadataBatchReport(jobId: string) {
return apiFetch<MetadataBatchReportDto>(`/metadata/batch/${jobId}/report`);
}
export async function getMetadataBatchResults(jobId: string, status?: string) {
const params = status ? `?status=${status}` : "";
return apiFetch<MetadataBatchResultDto[]>(`/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;
};