feat(api,web): providers ComicVine + MangaDex et pilotage de l'enrichissement

- Adaptateurs comic-vine et mangadex avec helper de fetch partagé,
  timeouts et fallback durcis sur les providers existants
- Scoring des correspondances amélioré, normalisation de la date de
  publication, chaîne de résolution des providers étendue
- Scanner : statuts par livre (scan/enrichissement) et page admin
  d'automatisation alignée

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Git Agent
2026-08-24 09:11:18 +02:00
parent 93bb40a8cd
commit d79f502dd2
25 changed files with 2812 additions and 231 deletions

View File

@ -4,16 +4,21 @@ import type {
AutomationFrequency,
AutomationScheduleDto,
AutomationSettingsDto,
MetadataProviderId,
MetadataSourcesConfigDto
} from "@readabook/shared";
import { api, getApiFallback } from "../api/client";
import { ErrorRibbon, LoadingState, Panel } from "../components/ui";
import {
type AdminMetadataProviderId,
type AdminMetadataSourcesConfig,
defaultMetadataSources,
metadataSourcesPayload,
moveSource,
normalizeMetadataSources,
providerLabels,
providerUiMessage,
providerUiState,
providerUiStateLabel,
scheduleDays,
scheduleSummary
} from "./adminAutomation";
@ -28,14 +33,9 @@ type ApiState<T> = {
success?: string;
};
const defaultMetadataConfig: MetadataSourcesConfigDto = {
const defaultMetadataConfig: AdminMetadataSourcesConfig = {
isbnPriorityEnabled: true,
sources: [
{ provider: "local", enabled: true, priority: 0, hasApiKey: false },
{ provider: "openlibrary", enabled: false, priority: 1, hasApiKey: false },
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: false },
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
]
sources: defaultMetadataSources
};
const defaultAutomationSettings: AutomationSettingsDto = {
@ -47,7 +47,7 @@ const defaultAutomationSettings: AutomationSettingsDto = {
export function AdminAutomationPage() {
const [tab, setTab] = useState<AdminAutomationTab>("sources");
const [metadataState, setMetadataState] = useState<ApiState<MetadataSourcesConfigDto>>({
const [metadataState, setMetadataState] = useState<ApiState<AdminMetadataSourcesConfig>>({
initial: null,
draft: null,
loading: true,
@ -59,7 +59,7 @@ export function AdminAutomationPage() {
loading: true,
saving: false
});
const [apiKeys, setApiKeys] = useState<Partial<Record<MetadataProviderId, string>>>({});
const [apiKeys, setApiKeys] = useState<Partial<Record<AdminMetadataProviderId, string>>>({});
const metadataDirty = useMemo(
() => Boolean(metadataState.initial && metadataState.draft && JSON.stringify(metadataState.initial) !== JSON.stringify(metadataState.draft)),
@ -78,7 +78,7 @@ export function AdminAutomationPage() {
setMetadataState({ initial: next, draft: next, loading: false, saving: false });
setApiKeys({});
} catch (error) {
const fallback = getApiFallback<MetadataSourcesConfigDto>(error);
const fallback = getApiFallback<MetadataSourcesConfigDto | AdminMetadataSourcesConfig>(error);
const next = normalizeMetadataSources(fallback ?? defaultMetadataConfig);
setMetadataState({
initial: next,
@ -226,11 +226,11 @@ function MetadataSourcesPanel({
onSubmit,
onRefresh
}: {
state: ApiState<MetadataSourcesConfigDto>;
state: ApiState<AdminMetadataSourcesConfig>;
dirty: boolean;
apiKeys: Partial<Record<MetadataProviderId, string>>;
setApiKeys: (next: Partial<Record<MetadataProviderId, string>>) => void;
onChange: (draft: MetadataSourcesConfigDto) => void;
apiKeys: Partial<Record<AdminMetadataProviderId, string>>;
setApiKeys: (next: Partial<Record<AdminMetadataProviderId, string>>) => void;
onChange: (draft: AdminMetadataSourcesConfig) => void;
onSubmit: (event: FormEvent) => void;
onRefresh: () => Promise<void>;
}) {
@ -297,14 +297,25 @@ function MetadataSourcesPanel({
<small>{source.enabled ? "active" : "inactive"}</small>
</span>
</label>
<label>
Cle API
<input
value={apiKeys[source.provider] ?? ""}
onChange={(event) => setApiKeys({ ...apiKeys, [source.provider]: event.target.value })}
placeholder={source.hasApiKey ? "cle conservee" : "optionnelle"}
/>
</label>
<div className="provider-config">
<div className="provider-state-line">
<span className={`status-pill provider-state-${providerUiState(source)}`}>{providerUiStateLabel(source)}</span>
<small>{providerUiMessage(source)}</small>
</div>
{source.provider === "comicvine" && (
<p className="provider-warning">
Usage non commercial uniquement. Verifier la compatibilite avec l'usage de ReadaBook.
</p>
)}
<label>
Cle API
<input
value={apiKeys[source.provider] ?? ""}
onChange={(event) => setApiKeys({ ...apiKeys, [source.provider]: event.target.value })}
placeholder={source.hasApiKey ? "cle conservee" : source.requiresCredentials ? "requise" : "optionnelle"}
/>
</label>
</div>
<div className="provider-actions">
<button
className="ghost-button icon-button"