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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user