feat: add external metadata sync system with multiple providers
Add a complete metadata synchronization system allowing users to search and sync series/book metadata from external providers (Google Books, Open Library, ComicVine, AniList, Bédéthèque). Each library can use a different provider. Matching requires manual approval with detailed sync reports showing what was updated or skipped (locked fields protection). Key changes: - DB migrations: external_metadata_links, external_book_metadata tables, library metadata_provider column, locked_fields, total_volumes, book metadata fields (summary, isbn, publish_date) - Rust API: MetadataProvider trait + 5 provider implementations, 7 metadata endpoints (search, match, approve, reject, links, missing, delete), sync report system, provider language preference support - Backoffice: MetadataSearchModal, ProviderIcon, SafeHtml components, settings UI for provider/language config, enriched book detail page, edit forms with locked fields support, API proxy routes - OpenAPI/Swagger documentation for all new endpoints and schemas Closes #3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, BookDto, SeriesMetadataDto } from "../../../../../lib/api";
|
||||
import { fetchLibraries, fetchBooks, fetchSeriesMetadata, getBookCoverUrl, getMetadataLink, getMissingBooks, BookDto, SeriesMetadataDto, ExternalMetadataLinkDto, MissingBooksDto } from "../../../../../lib/api";
|
||||
import { BooksGrid, EmptyState } from "../../../../components/BookCard";
|
||||
import { MarkSeriesReadButton } from "../../../../components/MarkSeriesReadButton";
|
||||
import { MarkBookReadButton } from "../../../../components/MarkBookReadButton";
|
||||
import { EditSeriesForm } from "../../../../components/EditSeriesForm";
|
||||
import { MetadataSearchModal } from "../../../../components/MetadataSearchModal";
|
||||
import { OffsetPagination } from "../../../../components/ui";
|
||||
import { SafeHtml } from "../../../../components/SafeHtml";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
@@ -24,7 +26,7 @@ export default async function SeriesDetailPage({
|
||||
|
||||
const seriesName = decodeURIComponent(name);
|
||||
|
||||
const [library, booksPage, seriesMeta] = await Promise.all([
|
||||
const [library, booksPage, seriesMeta, metadataLinks] = await Promise.all([
|
||||
fetchLibraries().then((libs) => libs.find((l) => l.id === id)),
|
||||
fetchBooks(id, seriesName, page, limit).catch(() => ({
|
||||
items: [] as BookDto[],
|
||||
@@ -33,8 +35,15 @@ export default async function SeriesDetailPage({
|
||||
limit,
|
||||
})),
|
||||
fetchSeriesMetadata(id, seriesName).catch(() => null as SeriesMetadataDto | null),
|
||||
getMetadataLink(id, seriesName).catch(() => [] as ExternalMetadataLinkDto[]),
|
||||
]);
|
||||
|
||||
const existingLink = metadataLinks.find((l) => l.status === "approved") ?? metadataLinks[0] ?? null;
|
||||
let missingData: MissingBooksDto | null = null;
|
||||
if (existingLink && existingLink.status === "approved") {
|
||||
missingData = await getMissingBooks(existingLink.id).catch(() => null);
|
||||
}
|
||||
|
||||
if (!library) {
|
||||
notFound();
|
||||
}
|
||||
@@ -96,7 +105,7 @@ export default async function SeriesDetailPage({
|
||||
)}
|
||||
|
||||
{seriesMeta?.description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{seriesMeta.description}</p>
|
||||
<SafeHtml html={seriesMeta.description} className="text-sm text-muted-foreground leading-relaxed" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
@@ -143,6 +152,14 @@ export default async function SeriesDetailPage({
|
||||
currentBookLanguage={seriesMeta?.book_language ?? booksPage.items[0]?.language ?? null}
|
||||
currentDescription={seriesMeta?.description ?? null}
|
||||
currentStartYear={seriesMeta?.start_year ?? null}
|
||||
currentTotalVolumes={seriesMeta?.total_volumes ?? null}
|
||||
currentLockedFields={seriesMeta?.locked_fields ?? {}}
|
||||
/>
|
||||
<MetadataSearchModal
|
||||
libraryId={id}
|
||||
seriesName={seriesName}
|
||||
existingLink={existingLink}
|
||||
initialMissing={missingData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -114,6 +114,7 @@ export default async function LibrariesPage() {
|
||||
monitorEnabled={lib.monitor_enabled}
|
||||
scanMode={lib.scan_mode}
|
||||
watcherEnabled={lib.watcher_enabled}
|
||||
metadataProvider={lib.metadata_provider}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
Reference in New Issue
Block a user