- 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>
518 lines
18 KiB
TypeScript
518 lines
18 KiB
TypeScript
import { FormEvent, useEffect, useMemo, useState } from "react";
|
|
import { ArrowDown, ArrowUp, Play, Save } from "lucide-react";
|
|
import type {
|
|
AutomationFrequency,
|
|
AutomationScheduleDto,
|
|
AutomationSettingsDto,
|
|
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";
|
|
|
|
type AdminAutomationTab = "sources" | "automation";
|
|
type ApiState<T> = {
|
|
initial: T | null;
|
|
draft: T | null;
|
|
loading: boolean;
|
|
saving: boolean;
|
|
error?: string;
|
|
success?: string;
|
|
};
|
|
|
|
const defaultMetadataConfig: AdminMetadataSourcesConfig = {
|
|
isbnPriorityEnabled: true,
|
|
sources: defaultMetadataSources
|
|
};
|
|
|
|
const defaultAutomationSettings: AutomationSettingsDto = {
|
|
watchLibraries: false,
|
|
autoEnrichNewBooks: false,
|
|
scanSchedule: { frequency: "disabled", time: "03:00", dayOfWeek: 1 },
|
|
enrichSchedule: { frequency: "disabled", time: "04:00", dayOfWeek: 1 }
|
|
};
|
|
|
|
export function AdminAutomationPage() {
|
|
const [tab, setTab] = useState<AdminAutomationTab>("sources");
|
|
const [metadataState, setMetadataState] = useState<ApiState<AdminMetadataSourcesConfig>>({
|
|
initial: null,
|
|
draft: null,
|
|
loading: true,
|
|
saving: false
|
|
});
|
|
const [automationState, setAutomationState] = useState<ApiState<AutomationSettingsDto>>({
|
|
initial: null,
|
|
draft: null,
|
|
loading: true,
|
|
saving: false
|
|
});
|
|
const [apiKeys, setApiKeys] = useState<Partial<Record<AdminMetadataProviderId, string>>>({});
|
|
|
|
const metadataDirty = useMemo(
|
|
() => Boolean(metadataState.initial && metadataState.draft && JSON.stringify(metadataState.initial) !== JSON.stringify(metadataState.draft)),
|
|
[metadataState.initial, metadataState.draft]
|
|
);
|
|
const automationDirty = useMemo(
|
|
() =>
|
|
Boolean(automationState.initial && automationState.draft && JSON.stringify(automationState.initial) !== JSON.stringify(automationState.draft)),
|
|
[automationState.initial, automationState.draft]
|
|
);
|
|
|
|
async function refreshMetadata() {
|
|
setMetadataState((current) => ({ ...current, loading: true, error: undefined, success: undefined }));
|
|
try {
|
|
const next = normalizeMetadataSources(await api.metadataSources());
|
|
setMetadataState({ initial: next, draft: next, loading: false, saving: false });
|
|
setApiKeys({});
|
|
} catch (error) {
|
|
const fallback = getApiFallback<MetadataSourcesConfigDto | AdminMetadataSourcesConfig>(error);
|
|
const next = normalizeMetadataSources(fallback ?? defaultMetadataConfig);
|
|
setMetadataState({
|
|
initial: next,
|
|
draft: next,
|
|
loading: false,
|
|
saving: false,
|
|
error: fallback ? "Sources chargees en mode degrade." : "Lecture des sources impossible."
|
|
});
|
|
}
|
|
}
|
|
|
|
async function refreshAutomation() {
|
|
setAutomationState((current) => ({ ...current, loading: true, error: undefined, success: undefined }));
|
|
try {
|
|
const next = await api.automationSettings();
|
|
setAutomationState({ initial: next, draft: next, loading: false, saving: false });
|
|
} catch (error) {
|
|
const fallback = getApiFallback<AutomationSettingsDto>(error);
|
|
const next = fallback ?? defaultAutomationSettings;
|
|
setAutomationState({
|
|
initial: next,
|
|
draft: next,
|
|
loading: false,
|
|
saving: false,
|
|
error: fallback ? "Automatisation chargee en mode degrade." : "Lecture de l'automatisation impossible."
|
|
});
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
void refreshMetadata();
|
|
void refreshAutomation();
|
|
}, []);
|
|
|
|
async function saveMetadata(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!metadataState.draft) return;
|
|
setMetadataState((current) => ({ ...current, saving: true, error: undefined, success: undefined }));
|
|
try {
|
|
const payload = metadataSourcesPayload(metadataState.draft);
|
|
payload.sources = payload.sources?.map((source) => {
|
|
const apiKey = apiKeys[source.provider]?.trim();
|
|
return apiKey ? { ...source, apiKey } : source;
|
|
});
|
|
const next = normalizeMetadataSources(await api.updateMetadataSources(payload));
|
|
setMetadataState({ initial: next, draft: next, loading: false, saving: false, success: "Sources enregistrees." });
|
|
setApiKeys({});
|
|
} catch (error) {
|
|
setMetadataState((current) => ({
|
|
...current,
|
|
saving: false,
|
|
error: error instanceof Error ? error.message : "Enregistrement des sources impossible."
|
|
}));
|
|
}
|
|
}
|
|
|
|
async function saveAutomation(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!automationState.draft) return;
|
|
setAutomationState((current) => ({ ...current, saving: true, error: undefined, success: undefined }));
|
|
try {
|
|
const next = await api.updateAutomationSettings(automationState.draft);
|
|
setAutomationState({ initial: next, draft: next, loading: false, saving: false, success: "Automatisation enregistree." });
|
|
} catch (error) {
|
|
setAutomationState((current) => ({
|
|
...current,
|
|
saving: false,
|
|
error: error instanceof Error ? error.message : "Enregistrement de l'automatisation impossible."
|
|
}));
|
|
}
|
|
}
|
|
|
|
async function runNow(kind: "scan" | "enrich") {
|
|
setAutomationState((current) => ({ ...current, error: undefined, success: undefined }));
|
|
try {
|
|
if (kind === "scan") await api.runAutomationScan();
|
|
else await api.runAutomationEnrich();
|
|
setAutomationState((current) => ({
|
|
...current,
|
|
success: kind === "scan" ? "Scan planifie demande." : "Enrichissement planifie demande."
|
|
}));
|
|
} catch (error) {
|
|
setAutomationState((current) => ({
|
|
...current,
|
|
error: error instanceof Error ? error.message : "Demande impossible."
|
|
}));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="page-grid">
|
|
<Panel className="span-3">
|
|
<div className="section-heading">
|
|
<div>
|
|
<h1>Automatisation & enrichissement</h1>
|
|
<p className="muted-copy">Sources, priorites et taches recurrentes.</p>
|
|
</div>
|
|
<span>{metadataDirty || automationDirty ? "modifications non enregistrees" : "a jour"}</span>
|
|
</div>
|
|
<div className="admin-tabs" role="tablist" aria-label="Automatisation admin">
|
|
<button className={tab === "sources" ? "active" : ""} onClick={() => setTab("sources")} role="tab" aria-selected={tab === "sources"}>
|
|
Sources de métadonnées
|
|
</button>
|
|
<button
|
|
className={tab === "automation" ? "active" : ""}
|
|
onClick={() => setTab("automation")}
|
|
role="tab"
|
|
aria-selected={tab === "automation"}
|
|
>
|
|
Automatisation
|
|
</button>
|
|
</div>
|
|
</Panel>
|
|
|
|
{tab === "sources" ? (
|
|
<MetadataSourcesPanel
|
|
state={metadataState}
|
|
dirty={metadataDirty}
|
|
apiKeys={apiKeys}
|
|
setApiKeys={setApiKeys}
|
|
onChange={(draft) => setMetadataState((current) => ({ ...current, draft, success: undefined }))}
|
|
onSubmit={saveMetadata}
|
|
onRefresh={refreshMetadata}
|
|
/>
|
|
) : (
|
|
<AutomationPanel
|
|
state={automationState}
|
|
dirty={automationDirty}
|
|
onChange={(draft) => setAutomationState((current) => ({ ...current, draft, success: undefined }))}
|
|
onSubmit={saveAutomation}
|
|
onRefresh={refreshAutomation}
|
|
onRunNow={runNow}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetadataSourcesPanel({
|
|
state,
|
|
dirty,
|
|
apiKeys,
|
|
setApiKeys,
|
|
onChange,
|
|
onSubmit,
|
|
onRefresh
|
|
}: {
|
|
state: ApiState<AdminMetadataSourcesConfig>;
|
|
dirty: boolean;
|
|
apiKeys: Partial<Record<AdminMetadataProviderId, string>>;
|
|
setApiKeys: (next: Partial<Record<AdminMetadataProviderId, string>>) => void;
|
|
onChange: (draft: AdminMetadataSourcesConfig) => void;
|
|
onSubmit: (event: FormEvent) => void;
|
|
onRefresh: () => Promise<void>;
|
|
}) {
|
|
const draft = state.draft;
|
|
if (state.loading && !draft) return <LoadingPanel label="Lecture des sources" />;
|
|
if (!draft) return null;
|
|
|
|
const local = draft.sources.find((source) => source.provider === "local");
|
|
const external = draft.sources.filter((source) => source.provider !== "local");
|
|
|
|
return (
|
|
<form className="span-3 automation-grid" onSubmit={onSubmit}>
|
|
<Panel className="span-3">
|
|
<div className="section-heading compact-heading">
|
|
<div>
|
|
<h2>Sources de métadonnées</h2>
|
|
<p className="muted-copy">La source locale reste active en premier passage.</p>
|
|
</div>
|
|
<StatusText dirty={dirty} loading={state.loading} />
|
|
</div>
|
|
<ErrorRibbon message={state.error} />
|
|
{state.success && <div className="success-ribbon">{state.success}</div>}
|
|
<label className="toggle-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.isbnPriorityEnabled}
|
|
onChange={(event) => onChange({ ...draft, isbnPriorityEnabled: event.target.checked })}
|
|
/>
|
|
<span>
|
|
<strong>Priorite ISBN globale</strong>
|
|
<small>Les correspondances ISBN passent avant les rapprochements titre/auteur.</small>
|
|
</span>
|
|
</label>
|
|
</Panel>
|
|
|
|
<Panel className="span-3">
|
|
<div className="provider-list">
|
|
{local && (
|
|
<div className="provider-row provider-local">
|
|
<div>
|
|
<strong>{providerLabels.local}</strong>
|
|
<span>Source locale</span>
|
|
</div>
|
|
<span className="status-pill active">toujours active</span>
|
|
</div>
|
|
)}
|
|
{external.map((source, index) => (
|
|
<div className="provider-row" key={source.provider}>
|
|
<label className="toggle-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={source.enabled}
|
|
onChange={(event) =>
|
|
onChange({
|
|
...draft,
|
|
sources: draft.sources.map((item) =>
|
|
item.provider === source.provider ? { ...item, enabled: event.target.checked } : item
|
|
)
|
|
})
|
|
}
|
|
/>
|
|
<span>
|
|
<strong>{providerLabels[source.provider]}</strong>
|
|
<small>{source.enabled ? "active" : "inactive"}</small>
|
|
</span>
|
|
</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"
|
|
type="button"
|
|
title="Monter"
|
|
disabled={index === 0}
|
|
onClick={() => onChange({ ...draft, sources: moveSource(draft.sources, source.provider, -1) })}
|
|
>
|
|
<ArrowUp size={16} />
|
|
</button>
|
|
<button
|
|
className="ghost-button icon-button"
|
|
type="button"
|
|
title="Descendre"
|
|
disabled={index === external.length - 1}
|
|
onClick={() => onChange({ ...draft, sources: moveSource(draft.sources, source.provider, 1) })}
|
|
>
|
|
<ArrowDown size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Panel>
|
|
|
|
<SaveBar dirty={dirty} saving={state.saving} onRefresh={onRefresh} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function AutomationPanel({
|
|
state,
|
|
dirty,
|
|
onChange,
|
|
onSubmit,
|
|
onRefresh,
|
|
onRunNow
|
|
}: {
|
|
state: ApiState<AutomationSettingsDto>;
|
|
dirty: boolean;
|
|
onChange: (draft: AutomationSettingsDto) => void;
|
|
onSubmit: (event: FormEvent) => void;
|
|
onRefresh: () => Promise<void>;
|
|
onRunNow: (kind: "scan" | "enrich") => Promise<void>;
|
|
}) {
|
|
const draft = state.draft;
|
|
if (state.loading && !draft) return <LoadingPanel label="Lecture de l'automatisation" />;
|
|
if (!draft) return null;
|
|
|
|
return (
|
|
<form className="span-3 automation-grid" onSubmit={onSubmit}>
|
|
<Panel className="span-3">
|
|
<div className="section-heading compact-heading">
|
|
<div>
|
|
<h2>Automatisation</h2>
|
|
<p className="muted-copy">Surveillance des dossiers et traitements planifies.</p>
|
|
</div>
|
|
<StatusText dirty={dirty} loading={state.loading} />
|
|
</div>
|
|
<ErrorRibbon message={state.error} />
|
|
{state.success && <div className="success-ribbon">{state.success}</div>}
|
|
<div className="toggle-stack">
|
|
<label className="toggle-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.watchLibraries}
|
|
onChange={(event) => onChange({ ...draft, watchLibraries: event.target.checked })}
|
|
/>
|
|
<span>
|
|
<strong>Watch auto</strong>
|
|
<small>Les bibliotheques actives declenchent un scan quand un fichier change.</small>
|
|
</span>
|
|
</label>
|
|
<label className="toggle-row">
|
|
<input
|
|
type="checkbox"
|
|
checked={draft.autoEnrichNewBooks}
|
|
onChange={(event) => onChange({ ...draft, autoEnrichNewBooks: event.target.checked })}
|
|
/>
|
|
<span>
|
|
<strong>Auto enrich new files</strong>
|
|
<small>Les nouveaux livres passent par la chaine d'enrichissement active.</small>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
</Panel>
|
|
|
|
<SchedulePanel
|
|
title="Scan planifié"
|
|
schedule={draft.scanSchedule}
|
|
summary={scheduleSummary(draft.scanSchedule, "Scan")}
|
|
runLabel="Lancer un scan"
|
|
onRun={() => onRunNow("scan")}
|
|
onChange={(scanSchedule) => onChange({ ...draft, scanSchedule })}
|
|
/>
|
|
<SchedulePanel
|
|
title="Enrichissement planifié"
|
|
schedule={draft.enrichSchedule}
|
|
summary={scheduleSummary(draft.enrichSchedule, "Enrichissement")}
|
|
runLabel="Lancer l'enrichissement"
|
|
onRun={() => onRunNow("enrich")}
|
|
onChange={(enrichSchedule) => onChange({ ...draft, enrichSchedule })}
|
|
/>
|
|
|
|
<SaveBar dirty={dirty} saving={state.saving} onRefresh={onRefresh} />
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function SchedulePanel({
|
|
title,
|
|
schedule,
|
|
summary,
|
|
runLabel,
|
|
onRun,
|
|
onChange
|
|
}: {
|
|
title: string;
|
|
schedule: AutomationScheduleDto;
|
|
summary: string;
|
|
runLabel: string;
|
|
onRun: () => Promise<void>;
|
|
onChange: (schedule: AutomationScheduleDto) => void;
|
|
}) {
|
|
function patch(next: Partial<AutomationScheduleDto>) {
|
|
onChange({ ...schedule, ...next });
|
|
}
|
|
|
|
return (
|
|
<Panel>
|
|
<div className="section-heading compact-heading">
|
|
<div>
|
|
<h2>{title}</h2>
|
|
<p className="muted-copy">{summary}</p>
|
|
</div>
|
|
<button className="ghost-button icon-text-button" type="button" onClick={() => void onRun()}>
|
|
<Play size={16} />
|
|
{runLabel}
|
|
</button>
|
|
</div>
|
|
<div className="schedule-controls">
|
|
<label>
|
|
Frequence
|
|
<select value={schedule.frequency} onChange={(event) => patch({ frequency: event.target.value as AutomationFrequency })}>
|
|
<option value="disabled">Desactive</option>
|
|
<option value="daily">Daily</option>
|
|
<option value="weekly">Weekly</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
Heure
|
|
<input type="time" value={schedule.time} onChange={(event) => patch({ time: event.target.value })} />
|
|
</label>
|
|
{schedule.frequency === "weekly" && (
|
|
<label>
|
|
Jour
|
|
<select value={schedule.dayOfWeek} onChange={(event) => patch({ dayOfWeek: Number(event.target.value) })}>
|
|
{scheduleDays.map((day) => (
|
|
<option value={day.value} key={day.value}>
|
|
{day.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
function SaveBar({ dirty, saving, onRefresh }: { dirty: boolean; saving: boolean; onRefresh: () => Promise<void> }) {
|
|
return (
|
|
<Panel className="span-3 save-bar">
|
|
<span>{dirty ? "Modifications en attente." : "Aucune modification en attente."}</span>
|
|
<div>
|
|
<button className="ghost-button" type="button" onClick={() => void onRefresh()} disabled={saving}>
|
|
Recharger
|
|
</button>
|
|
<button className="primary-button" type="submit" disabled={!dirty || saving}>
|
|
<Save size={16} />
|
|
{saving ? "Enregistrement" : "Enregistrer"}
|
|
</button>
|
|
</div>
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
function StatusText({ dirty, loading }: { dirty: boolean; loading: boolean }) {
|
|
if (loading) return <span>chargement</span>;
|
|
return <span>{dirty ? "non enregistre" : "synchronise"}</span>;
|
|
}
|
|
|
|
function LoadingPanel({ label }: { label: string }) {
|
|
return (
|
|
<Panel className="span-3">
|
|
<LoadingState label={label} />
|
|
</Panel>
|
|
);
|
|
}
|