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:
2026-03-18 14:59:24 +01:00
parent a99bfb5a91
commit c9ccf5cd90
42 changed files with 5492 additions and 198 deletions

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, FormRow, Icon } from "../components/ui";
import { ProviderIcon } from "../components/ProviderIcon";
import { Settings, CacheStats, ClearCacheResponse, ThumbnailStats, KomgaSyncResponse, KomgaSyncReportSummary } from "../../lib/api";
interface SettingsPageProps {
@@ -550,6 +551,9 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</>)}
{activeTab === "integrations" && (<>
{/* Metadata Providers */}
<MetadataProvidersCard handleUpdateSetting={handleUpdateSetting} />
{/* Komga Sync */}
<Card className="mb-6">
<CardHeader>
@@ -793,3 +797,170 @@ export default function SettingsPage({ initialSettings, initialCacheStats, initi
</>
);
}
// ---------------------------------------------------------------------------
// Metadata Providers sub-component
// ---------------------------------------------------------------------------
const METADATA_LANGUAGES = [
{ value: "en", label: "English" },
{ value: "fr", label: "Français" },
{ value: "es", label: "Español" },
] as const;
function MetadataProvidersCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
const [defaultProvider, setDefaultProvider] = useState("google_books");
const [metadataLanguage, setMetadataLanguage] = useState("en");
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
useEffect(() => {
fetch("/api/settings/metadata_providers")
.then((r) => (r.ok ? r.json() : null))
.then((data) => {
if (data) {
if (data.default_provider) setDefaultProvider(data.default_provider);
if (data.metadata_language) setMetadataLanguage(data.metadata_language);
if (data.comicvine?.api_key) setApiKeys((prev) => ({ ...prev, comicvine: data.comicvine.api_key }));
if (data.google_books?.api_key) setApiKeys((prev) => ({ ...prev, google_books: data.google_books.api_key }));
}
})
.catch(() => {});
}, []);
function save(provider: string, lang: string, keys: Record<string, string>) {
const value: Record<string, unknown> = {
default_provider: provider,
metadata_language: lang,
};
for (const [k, v] of Object.entries(keys)) {
if (v) value[k] = { api_key: v };
}
handleUpdateSetting("metadata_providers", value);
}
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="search" size="md" />
Metadata Providers
</CardTitle>
<CardDescription>Configure external metadata providers for series/book enrichment. Each library can override the default provider. All providers are available for quick-search in the metadata modal.</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Default provider */}
<div>
<label className="text-sm font-medium text-muted-foreground mb-2 block">Default Provider</label>
<div className="flex gap-2 flex-wrap">
{([
{ value: "google_books", label: "Google Books" },
{ value: "open_library", label: "Open Library" },
{ value: "comicvine", label: "ComicVine" },
{ value: "anilist", label: "AniList" },
{ value: "bedetheque", label: "Bédéthèque" },
] as const).map((p) => (
<button
key={p.value}
type="button"
onClick={() => {
setDefaultProvider(p.value);
save(p.value, metadataLanguage, apiKeys);
}}
className={`inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
defaultProvider === p.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
}`}
>
<ProviderIcon provider={p.value} size={18} />
{p.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">Used by default for metadata search. Libraries can override this individually.</p>
</div>
{/* Metadata language */}
<div>
<label className="text-sm font-medium text-muted-foreground mb-2 block">Metadata Language</label>
<div className="flex gap-2">
{METADATA_LANGUAGES.map((l) => (
<button
key={l.value}
type="button"
onClick={() => {
setMetadataLanguage(l.value);
save(defaultProvider, l.value, apiKeys);
}}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
metadataLanguage === l.value
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground hover:border-primary/50"
}`}
>
{l.label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground mt-2">Preferred language for search results and descriptions. Fallback: English.</p>
</div>
{/* Provider API keys — always visible */}
<div className="border-t border-border/50 pt-4">
<h4 className="text-sm font-medium text-foreground mb-3">API Keys</h4>
<div className="space-y-4">
<FormField>
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
<ProviderIcon provider="google_books" size={16} />
Google Books API Key
</label>
<FormInput
type="password"
placeholder="Optional — for higher rate limits"
value={apiKeys.google_books || ""}
onChange={(e) => setApiKeys({ ...apiKeys, google_books: e.target.value })}
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
/>
<p className="text-xs text-muted-foreground mt-1">Works without a key but with lower rate limits.</p>
</FormField>
<FormField>
<label className="text-sm font-medium text-muted-foreground mb-1 flex items-center gap-1.5">
<ProviderIcon provider="comicvine" size={16} />
ComicVine API Key
</label>
<FormInput
type="password"
placeholder="Required to use ComicVine"
value={apiKeys.comicvine || ""}
onChange={(e) => setApiKeys({ ...apiKeys, comicvine: e.target.value })}
onBlur={() => save(defaultProvider, metadataLanguage, apiKeys)}
/>
<p className="text-xs text-muted-foreground mt-1">Get your key at <span className="font-mono text-foreground/70">comicvine.gamespot.com/api</span>.</p>
</FormField>
<div className="p-3 rounded-lg bg-muted/30 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1.5">
<ProviderIcon provider="open_library" size={16} />
<span className="text-xs font-medium text-foreground">Open Library</span>
</div>
<span className="text-xs text-muted-foreground">,</span>
<div className="flex items-center gap-1.5">
<ProviderIcon provider="anilist" size={16} />
<span className="text-xs font-medium text-foreground">AniList</span>
</div>
<span className="text-xs text-muted-foreground">and</span>
<div className="flex items-center gap-1.5">
<ProviderIcon provider="bedetheque" size={16} />
<span className="text-xs font-medium text-foreground">Bédéthèque</span>
</div>
<span className="text-xs text-muted-foreground">are free and require no API key.</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}