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:
@ -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"
|
||||
|
||||
@ -2,8 +2,13 @@ import { FormEvent, useEffect, useState } from "react";
|
||||
import { Play, Plus, Trash2 } from "lucide-react";
|
||||
import type { JobDto, LibraryDto, UserDto } from "@readabook/shared";
|
||||
import { api, getApiFallback } from "../api/client";
|
||||
import { jobDigestSummary } from "../book/metadata";
|
||||
import { EmptyState, ErrorRibbon, LoadingState, Panel } from "../components/ui";
|
||||
|
||||
function formatJobTime(value: string) {
|
||||
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const [libraries, setLibraries] = useState<LibraryDto[]>([]);
|
||||
const [jobs, setJobs] = useState<JobDto[]>([]);
|
||||
@ -161,7 +166,13 @@ export function AdminPage() {
|
||||
<div className="job-list">
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id}>
|
||||
<strong>{job.type}</strong>
|
||||
<div className="job-copy">
|
||||
<div>
|
||||
<strong>{job.type}</strong>
|
||||
<small>{jobDigestSummary(job)}</small>
|
||||
</div>
|
||||
<time dateTime={job.updatedAt}>{formatJobTime(job.updatedAt)}</time>
|
||||
</div>
|
||||
<span>{job.status}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,14 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MetadataSourcesConfigDto } from "@readabook/shared";
|
||||
import { metadataSourcesPayload, moveSource, normalizeMetadataSources, scheduleSummary } from "./adminAutomation";
|
||||
import type { AdminMetadataSourcesConfig } from "./adminAutomation";
|
||||
import {
|
||||
metadataSourcesPayload,
|
||||
moveSource,
|
||||
normalizeMetadataSources,
|
||||
providerLabels,
|
||||
providerUiMessage,
|
||||
providerUiStateLabel,
|
||||
scheduleSummary
|
||||
} from "./adminAutomation";
|
||||
|
||||
const config: MetadataSourcesConfigDto = {
|
||||
const config: AdminMetadataSourcesConfig = {
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [
|
||||
{ provider: "googlebooks", enabled: false, priority: 2, hasApiKey: true },
|
||||
{ provider: "local", enabled: false, priority: 99, hasApiKey: false },
|
||||
{ provider: "openlibrary", enabled: true, priority: 1, hasApiKey: false },
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false }
|
||||
{ provider: "bnf", enabled: false, priority: 3, hasApiKey: false },
|
||||
{ provider: "comicvine", enabled: true, priority: 4, hasApiKey: false, requiresCredentials: true },
|
||||
{ provider: "mangadex", enabled: false, priority: 5, hasApiKey: false }
|
||||
]
|
||||
};
|
||||
|
||||
@ -27,17 +37,44 @@ describe("admin automation helpers", () => {
|
||||
sources: [
|
||||
{ provider: "openlibrary", enabled: true, priority: 1 },
|
||||
{ provider: "googlebooks", enabled: false, priority: 2 },
|
||||
{ provider: "bnf", enabled: false, priority: 3 }
|
||||
{ provider: "bnf", enabled: false, priority: 3 },
|
||||
{ provider: "comicvine", enabled: true, priority: 4 },
|
||||
{ provider: "mangadex", enabled: false, priority: 5 }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it("moves only external providers", () => {
|
||||
const moved = moveSource(normalizeMetadataSources(config).sources, "bnf", -1);
|
||||
expect(moved.map((source) => source.provider)).toEqual(["local", "openlibrary", "bnf", "googlebooks"]);
|
||||
expect(moved.map((source) => source.provider)).toEqual(["local", "openlibrary", "bnf", "googlebooks", "comicvine", "mangadex"]);
|
||||
});
|
||||
|
||||
it("summarizes weekly schedules", () => {
|
||||
expect(scheduleSummary({ frequency: "weekly", time: "04:30", dayOfWeek: 1 }, "Scan")).toBe("Scan chaque lundi a 04:30.");
|
||||
});
|
||||
|
||||
it("adds Comic Vine and MangaDex when the backend omits them", () => {
|
||||
const normalized = normalizeMetadataSources({
|
||||
isbnPriorityEnabled: true,
|
||||
sources: [{ provider: "local", enabled: true, priority: 0, hasApiKey: false }]
|
||||
});
|
||||
expect(normalized.sources.map((source) => source.provider)).toContain("comicvine");
|
||||
expect(normalized.sources.map((source) => source.provider)).toContain("mangadex");
|
||||
expect(providerLabels.comicvine).toBe("Comic Vine");
|
||||
expect(providerLabels.mangadex).toBe("MangaDex");
|
||||
});
|
||||
|
||||
it("labels provider configuration, rate limit and error states", () => {
|
||||
expect(providerUiStateLabel({ provider: "comicvine", enabled: true, priority: 1, hasApiKey: false, requiresCredentials: true })).toBe(
|
||||
"A configurer"
|
||||
);
|
||||
expect(providerUiMessage({ provider: "comicvine", enabled: true, priority: 1, hasApiKey: false, requiresCredentials: true })).toBe(
|
||||
"Source activee, configuration incomplete."
|
||||
);
|
||||
expect(providerUiStateLabel({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, rateLimited: true })).toBe("Limite");
|
||||
expect(providerUiMessage({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, status: "quota_exceeded" })).toBe(
|
||||
"Quota ou limite temporaire atteint. ReadaBook reessaiera plus tard."
|
||||
);
|
||||
expect(providerUiStateLabel({ provider: "mangadex", enabled: true, priority: 2, hasApiKey: false, lastError: "500 stack" })).toBe("Erreur");
|
||||
});
|
||||
});
|
||||
|
||||
@ -6,18 +6,57 @@ import type {
|
||||
UpdateMetadataSourcesConfigDto
|
||||
} from "@readabook/shared";
|
||||
|
||||
export const providerLabels: Record<MetadataProviderId, string> = {
|
||||
export type AdminMetadataProviderId = MetadataProviderId | "comicvine" | "mangadex";
|
||||
|
||||
export type AdminMetadataSourceConfig = Omit<MetadataSourceConfigDto, "provider"> & {
|
||||
provider: AdminMetadataProviderId;
|
||||
requiresCredentials?: boolean;
|
||||
status?: string | null;
|
||||
state?: string | null;
|
||||
health?: string | null;
|
||||
message?: string | null;
|
||||
lastError?: string | null;
|
||||
rateLimited?: boolean;
|
||||
quotaLimited?: boolean;
|
||||
};
|
||||
|
||||
export type AdminMetadataSourcesConfig = Omit<MetadataSourcesConfigDto, "sources"> & {
|
||||
sources: AdminMetadataSourceConfig[];
|
||||
};
|
||||
|
||||
export type ProviderUiState = "configured" | "missing-config" | "limited" | "error";
|
||||
|
||||
export const providerLabels: Record<AdminMetadataProviderId, string> = {
|
||||
local: "Fichier local",
|
||||
openlibrary: "OpenLibrary",
|
||||
googlebooks: "Google Books",
|
||||
bnf: "BnF"
|
||||
bnf: "BnF",
|
||||
comicvine: "Comic Vine",
|
||||
mangadex: "MangaDex"
|
||||
};
|
||||
|
||||
export const defaultMetadataSources: AdminMetadataSourceConfig[] = [
|
||||
{ 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 },
|
||||
{ provider: "comicvine", enabled: false, priority: 4, hasApiKey: false, requiresCredentials: true },
|
||||
{ provider: "mangadex", enabled: false, priority: 5, hasApiKey: false }
|
||||
];
|
||||
|
||||
const weekdays = ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"];
|
||||
|
||||
export function normalizeMetadataSources(config: MetadataSourcesConfigDto): MetadataSourcesConfigDto {
|
||||
const sorted = [...config.sources].sort((left, right) => left.priority - right.priority);
|
||||
const local = sorted.find((source) => source.provider === "local") ?? { provider: "local", enabled: true, priority: 0, hasApiKey: false };
|
||||
export function normalizeMetadataSources(config: MetadataSourcesConfigDto | AdminMetadataSourcesConfig): AdminMetadataSourcesConfig {
|
||||
const received = config.sources as AdminMetadataSourceConfig[];
|
||||
const merged = defaultMetadataSources.map((source) => ({
|
||||
...source,
|
||||
...received.find((item) => item.provider === source.provider)
|
||||
}));
|
||||
received.forEach((source) => {
|
||||
if (!merged.some((item) => item.provider === source.provider)) merged.push(source);
|
||||
});
|
||||
const sorted = merged.sort((left, right) => left.priority - right.priority);
|
||||
const local = sorted.find((source) => source.provider === "local") ?? defaultMetadataSources[0];
|
||||
const external = sorted.filter((source) => source.provider !== "local");
|
||||
return {
|
||||
isbnPriorityEnabled: config.isbnPriorityEnabled,
|
||||
@ -28,8 +67,8 @@ export function normalizeMetadataSources(config: MetadataSourcesConfigDto): Meta
|
||||
};
|
||||
}
|
||||
|
||||
export function metadataSourcesPayload(config: MetadataSourcesConfigDto): UpdateMetadataSourcesConfigDto {
|
||||
const sources: NonNullable<UpdateMetadataSourcesConfigDto["sources"]> = [];
|
||||
export function metadataSourcesPayload(config: AdminMetadataSourcesConfig): UpdateMetadataSourcesConfigDto {
|
||||
const sources: Array<{ provider: AdminMetadataProviderId; enabled: boolean; priority: number; apiKey?: string }> = [];
|
||||
config.sources.forEach((source) => {
|
||||
if (source.provider === "local") return;
|
||||
sources.push({
|
||||
@ -41,10 +80,10 @@ export function metadataSourcesPayload(config: MetadataSourcesConfigDto): Update
|
||||
return {
|
||||
isbnPriorityEnabled: config.isbnPriorityEnabled,
|
||||
sources
|
||||
};
|
||||
} as UpdateMetadataSourcesConfigDto;
|
||||
}
|
||||
|
||||
export function moveSource(sources: MetadataSourceConfigDto[], provider: MetadataProviderId, direction: -1 | 1): MetadataSourceConfigDto[] {
|
||||
export function moveSource(sources: AdminMetadataSourceConfig[], provider: AdminMetadataProviderId, direction: -1 | 1): AdminMetadataSourceConfig[] {
|
||||
const external = sources.filter((source) => source.provider !== "local");
|
||||
const index = external.findIndex((source) => source.provider === provider);
|
||||
const nextIndex = index + direction;
|
||||
@ -56,6 +95,34 @@ export function moveSource(sources: MetadataSourceConfigDto[], provider: Metadat
|
||||
return [local, ...nextExternal].map((source, priority) => ({ ...source, priority: source.provider === "local" ? 0 : priority }));
|
||||
}
|
||||
|
||||
function sourceStatusText(source: AdminMetadataSourceConfig): string {
|
||||
return [source.status, source.state, source.health].filter(Boolean).join(" ").toLowerCase();
|
||||
}
|
||||
|
||||
export function providerUiState(source: AdminMetadataSourceConfig): ProviderUiState {
|
||||
const status = sourceStatusText(source);
|
||||
if (source.rateLimited || source.quotaLimited || status.includes("limit") || status.includes("quota")) return "limited";
|
||||
if (source.lastError || status.includes("error") || status.includes("failed")) return "error";
|
||||
if (source.enabled && (source.requiresCredentials ?? false) && !source.hasApiKey) return "missing-config";
|
||||
return "configured";
|
||||
}
|
||||
|
||||
export function providerUiStateLabel(source: AdminMetadataSourceConfig): string {
|
||||
const state = providerUiState(source);
|
||||
if (state === "missing-config") return "A configurer";
|
||||
if (state === "limited") return "Limite";
|
||||
if (state === "error") return "Erreur";
|
||||
return "Configure";
|
||||
}
|
||||
|
||||
export function providerUiMessage(source: AdminMetadataSourceConfig): string {
|
||||
const state = providerUiState(source);
|
||||
if (state === "missing-config") return "Source activee, configuration incomplete.";
|
||||
if (state === "limited") return "Quota ou limite temporaire atteint. ReadaBook reessaiera plus tard.";
|
||||
if (state === "error") return "La derniere verification de cette source a echoue.";
|
||||
return source.enabled ? "Source prete." : "Source desactivee.";
|
||||
}
|
||||
|
||||
export function scheduleSummary(schedule: AutomationScheduleDto, subject: string): string {
|
||||
if (schedule.frequency === "disabled") return `${subject} desactive.`;
|
||||
if (schedule.frequency === "daily") return `${subject} tous les jours a ${schedule.time}.`;
|
||||
|
||||
Reference in New Issue
Block a user