feat: add series status, improve providers & e2e tests

- Add series status concept (ongoing/ended/hiatus/cancelled/upcoming)
  with normalization across all providers
- Add status field to series_metadata table (migration 0033)
- AniList: use chapters as fallback for volume count on ongoing series,
  add books_message when both volumes and chapters are null
- Bedetheque: extract description from meta tag, genres, parution status,
  origin/language; rewrite book parsing with itemprop microdata for
  clean ISBN, dates, page counts, covers; filter placeholder authors
- Add comprehensive e2e provider tests with field coverage reporting
- Wire status into EditSeriesForm, MetadataSearchModal, and series page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 16:10:45 +01:00
parent 51ef2fa725
commit 52b9b0e00e
10 changed files with 566 additions and 156 deletions

View File

@@ -39,6 +39,15 @@ function LockButton({
);
}
const SERIES_STATUSES = [
{ value: "", label: "Non défini" },
{ value: "ongoing", label: "En cours" },
{ value: "ended", label: "Terminée" },
{ value: "hiatus", label: "Hiatus" },
{ value: "cancelled", label: "Annulée" },
{ value: "upcoming", label: "À paraître" },
] as const;
interface EditSeriesFormProps {
libraryId: string;
seriesName: string;
@@ -49,6 +58,7 @@ interface EditSeriesFormProps {
currentDescription: string | null;
currentStartYear: number | null;
currentTotalVolumes: number | null;
currentStatus: string | null;
currentLockedFields: Record<string, boolean>;
}
@@ -62,6 +72,7 @@ export function EditSeriesForm({
currentDescription,
currentStartYear,
currentTotalVolumes,
currentStatus,
currentLockedFields,
}: EditSeriesFormProps) {
const router = useRouter();
@@ -80,6 +91,7 @@ export function EditSeriesForm({
const [description, setDescription] = useState(currentDescription ?? "");
const [startYear, setStartYear] = useState(currentStartYear?.toString() ?? "");
const [totalVolumes, setTotalVolumes] = useState(currentTotalVolumes?.toString() ?? "");
const [status, setStatus] = useState(currentStatus ?? "");
// Lock states
const [lockedFields, setLockedFields] = useState<Record<string, boolean>>(currentLockedFields);
@@ -142,6 +154,7 @@ export function EditSeriesForm({
setDescription(currentDescription ?? "");
setStartYear(currentStartYear?.toString() ?? "");
setTotalVolumes(currentTotalVolumes?.toString() ?? "");
setStatus(currentStatus ?? "");
setLockedFields(currentLockedFields);
setShowApplyToBooks(false);
setBookAuthor(currentBookAuthor ?? "");
@@ -182,6 +195,7 @@ export function EditSeriesForm({
description: description.trim() || null,
start_year: startYear.trim() ? parseInt(startYear.trim(), 10) : null,
total_volumes: totalVolumes.trim() ? parseInt(totalVolumes.trim(), 10) : null,
status: status || null,
locked_fields: lockedFields,
};
if (showApplyToBooks) {
@@ -285,6 +299,23 @@ export function EditSeriesForm({
/>
</FormField>
<FormField>
<div className="flex items-center gap-1">
<FormLabel>Statut</FormLabel>
<LockButton locked={!!lockedFields.status} onToggle={() => toggleLock("status")} disabled={isPending} />
</div>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
disabled={isPending}
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
>
{SERIES_STATUSES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</FormField>
{/* Auteurs — multi-valeur */}
<FormField className="sm:col-span-2">
<div className="flex items-center gap-1">

View File

@@ -13,6 +13,7 @@ const FIELD_LABELS: Record<string, string> = {
publishers: "Éditeurs",
start_year: "Année",
total_volumes: "Nb volumes",
status: "Statut",
summary: "Résumé",
isbn: "ISBN",
publish_date: "Date de publication",
@@ -338,7 +339,14 @@ export function MetadataSearchModal({
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{c.publishers.length > 0 && <span>{c.publishers[0]}</span>}
{c.start_year != null && <span>{c.start_year}</span>}
{c.total_volumes != null && <span>{c.total_volumes} vol.</span>}
{c.total_volumes != null && (
<span>
{c.total_volumes} {c.metadata_json?.volume_source === "chapters" ? "ch." : "vol."}
</span>
)}
{c.metadata_json?.status === "RELEASING" && (
<span className="italic text-amber-500">en cours</span>
)}
</div>
</div>
</div>
@@ -366,8 +374,11 @@ export function MetadataSearchModal({
{selectedCandidate.authors.length > 0 && (
<p className="text-sm text-muted-foreground">{selectedCandidate.authors.join(", ")}</p>
)}
{selectedCandidate.total_volumes && (
<p className="text-sm text-muted-foreground">{selectedCandidate.total_volumes} volumes</p>
{selectedCandidate.total_volumes != null && (
<p className="text-sm text-muted-foreground">
{selectedCandidate.total_volumes} {selectedCandidate.metadata_json?.volume_source === "chapters" ? "chapitres" : "volumes"}
{selectedCandidate.metadata_json?.status === "RELEASING" && <span className="italic text-amber-500 ml-1">(en cours)</span>}
</p>
)}
<p className="text-xs text-muted-foreground mt-1 inline-flex items-center gap-1">
via <ProviderIcon provider={selectedCandidate.provider} size={12} /> <span className="font-medium">{providerLabel(selectedCandidate.provider)}</span>
@@ -458,8 +469,15 @@ export function MetadataSearchModal({
</div>
)}
{/* Books message (e.g. provider has no volume data) */}
{syncReport.books_message && (
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<p className="text-xs text-amber-600">{syncReport.books_message}</p>
</div>
)}
{/* Books report */}
{(syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
{!syncReport.books_message && (syncReport.books.length > 0 || syncReport.books_unmatched > 0) && (
<div className="p-3 rounded-lg bg-muted/30 border border-border/50">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
Livres {syncReport.books_matched} matched{syncReport.books_unmatched > 0 && `, ${syncReport.books_unmatched} unmatched`}

View File

@@ -100,9 +100,27 @@ export default async function SeriesDetailPage({
<div className="flex-1 space-y-4">
<h1 className="text-3xl font-bold text-foreground">{displayName}</h1>
{seriesMeta && seriesMeta.authors.length > 0 && (
<p className="text-base text-muted-foreground">{seriesMeta.authors.join(", ")}</p>
)}
<div className="flex flex-wrap items-center gap-3">
{seriesMeta && seriesMeta.authors.length > 0 && (
<p className="text-base text-muted-foreground">{seriesMeta.authors.join(", ")}</p>
)}
{seriesMeta?.status && (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
seriesMeta.status === "ongoing" ? "bg-blue-500/15 text-blue-600" :
seriesMeta.status === "ended" ? "bg-green-500/15 text-green-600" :
seriesMeta.status === "hiatus" ? "bg-amber-500/15 text-amber-600" :
seriesMeta.status === "cancelled" ? "bg-red-500/15 text-red-600" :
"bg-muted text-muted-foreground"
}`}>
{seriesMeta.status === "ongoing" ? "En cours" :
seriesMeta.status === "ended" ? "Terminée" :
seriesMeta.status === "hiatus" ? "Hiatus" :
seriesMeta.status === "cancelled" ? "Annulée" :
seriesMeta.status === "upcoming" ? "À paraître" :
seriesMeta.status}
</span>
)}
</div>
{seriesMeta?.description && (
<SafeHtml html={seriesMeta.description} className="text-sm text-muted-foreground leading-relaxed" />
@@ -153,6 +171,7 @@ export default async function SeriesDetailPage({
currentDescription={seriesMeta?.description ?? null}
currentStartYear={seriesMeta?.start_year ?? null}
currentTotalVolumes={seriesMeta?.total_volumes ?? null}
currentStatus={seriesMeta?.status ?? null}
currentLockedFields={seriesMeta?.locked_fields ?? {}}
/>
<MetadataSearchModal