Add a period selector (day, week, month) to the reading activity and books added charts. The API now accepts a ?period= query param and returns gap-filled data using generate_series so all time slots appear even with zero values. Labels are locale-aware (short month, weekday). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1005 lines
25 KiB
TypeScript
1005 lines
25 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;
|
|
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<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 & { next?: { revalidate?: number; tags?: string[] } },
|
|
): 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 { 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<LibraryDto[]>("/libraries", { next: { revalidate: 30 } });
|
|
}
|
|
|
|
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, 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<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,
|
|
format?: string,
|
|
metadataProvider?: 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);
|
|
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<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,
|
|
author?: 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);
|
|
if (author) params.set("author", author);
|
|
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", { next: { revalidate: 300 } });
|
|
}
|
|
|
|
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", { next: { revalidate: 60 } });
|
|
}
|
|
|
|
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", { next: { revalidate: 30 } });
|
|
}
|
|
|
|
export async function clearCache() {
|
|
return apiFetch<ClearCacheResponse>("/settings/cache/clear", {
|
|
method: "POST",
|
|
});
|
|
}
|
|
|
|
export async function getThumbnailStats() {
|
|
return apiFetch<ThumbnailStats>("/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<StatusMappingDto[]> {
|
|
return apiFetch<StatusMappingDto[]>("/settings/status-mappings", { next: { revalidate: 60 } });
|
|
}
|
|
|
|
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 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 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[];
|
|
metadata: MetadataStats;
|
|
};
|
|
|
|
export async function fetchStats(period?: "day" | "week" | "month") {
|
|
const params = period && period !== "month" ? `?period=${period}` : "";
|
|
return apiFetch<StatsResponse>(`/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<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;
|
|
};
|