feat: add batch metadata jobs, series filters, and translate backoffice to French
- Add metadata_batch job type with background processing via tokio::spawn - Auto-apply metadata only when single result at 100% confidence - Support primary + fallback provider per library, "none" to opt out - Add batch report/results API endpoints and job detail UI - Add series_status and has_missing filters to both series listing pages - Add GET /series/statuses endpoint for dynamic filter options - Normalize series_metadata status values (migration 0036) - Hide ComicVine provider tab when no API key configured - Translate entire backoffice UI from English to French Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiFetch } from "../../../lib/api";
|
||||
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, MetadataBatchReportDto, MetadataBatchResultDto } from "../../../lib/api";
|
||||
import {
|
||||
Card, CardHeader, CardTitle, CardDescription, CardContent,
|
||||
StatusBadge, JobTypeBadge, StatBox, ProgressBar
|
||||
@@ -44,28 +44,33 @@ interface JobError {
|
||||
|
||||
const JOB_TYPE_INFO: Record<string, { label: string; description: string; isThumbnailOnly: boolean }> = {
|
||||
rebuild: {
|
||||
label: "Incremental index",
|
||||
description: "Scans for new/modified files, analyzes them and generates missing thumbnails.",
|
||||
label: "Indexation incrémentale",
|
||||
description: "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
full_rebuild: {
|
||||
label: "Full re-index",
|
||||
description: "Clears all existing data then performs a complete re-scan, re-analysis and thumbnail generation.",
|
||||
label: "Réindexation complète",
|
||||
description: "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
thumbnail_rebuild: {
|
||||
label: "Thumbnail rebuild",
|
||||
description: "Generates thumbnails only for books that are missing one. Existing thumbnails are preserved.",
|
||||
label: "Reconstruction des miniatures",
|
||||
description: "Génère les miniatures uniquement pour les livres qui n'en ont pas. Les miniatures existantes sont conservées.",
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
thumbnail_regenerate: {
|
||||
label: "Thumbnail regeneration",
|
||||
description: "Regenerates all thumbnails from scratch, replacing existing ones.",
|
||||
label: "Regénération des miniatures",
|
||||
description: "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes.",
|
||||
isThumbnailOnly: true,
|
||||
},
|
||||
cbr_to_cbz: {
|
||||
label: "CBR → CBZ conversion",
|
||||
description: "Converts a CBR archive to the open CBZ format.",
|
||||
label: "Conversion CBR → CBZ",
|
||||
description: "Convertit une archive CBR au format ouvert CBZ.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
metadata_batch: {
|
||||
label: "Métadonnées en lot",
|
||||
description: "Recherche les métadonnées auprès des fournisseurs externes pour toutes les séries de la bibliothèque et applique automatiquement les correspondances à 100% de confiance.",
|
||||
isThumbnailOnly: false,
|
||||
},
|
||||
};
|
||||
@@ -112,6 +117,18 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isMetadataBatch = job.type === "metadata_batch";
|
||||
|
||||
// Fetch batch report & results for metadata_batch jobs
|
||||
let batchReport: MetadataBatchReportDto | null = null;
|
||||
let batchResults: MetadataBatchResultDto[] = [];
|
||||
if (isMetadataBatch) {
|
||||
[batchReport, batchResults] = await Promise.all([
|
||||
getMetadataBatchReport(id).catch(() => null),
|
||||
getMetadataBatchResults(id).catch(() => []),
|
||||
]);
|
||||
}
|
||||
|
||||
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
|
||||
label: job.type,
|
||||
description: null,
|
||||
@@ -131,21 +148,25 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
const { isThumbnailOnly } = typeInfo;
|
||||
|
||||
// Which label to use for the progress card
|
||||
const progressTitle = isThumbnailOnly
|
||||
? "Thumbnails"
|
||||
: isExtractingPages
|
||||
? "Phase 2 — Extracting pages"
|
||||
: isThumbnailPhase
|
||||
? "Phase 2 — Thumbnails"
|
||||
: "Phase 1 — Discovery";
|
||||
const progressTitle = isMetadataBatch
|
||||
? "Recherche de métadonnées"
|
||||
: isThumbnailOnly
|
||||
? "Miniatures"
|
||||
: isExtractingPages
|
||||
? "Phase 2 — Extraction des pages"
|
||||
: isThumbnailPhase
|
||||
? "Phase 2 — Miniatures"
|
||||
: "Phase 1 — Découverte";
|
||||
|
||||
const progressDescription = isThumbnailOnly
|
||||
? undefined
|
||||
: isExtractingPages
|
||||
? "Extracting first page from each archive (page count + raw image)"
|
||||
: isThumbnailPhase
|
||||
? "Generating thumbnails for the analyzed books"
|
||||
: "Scanning and indexing files in the library";
|
||||
const progressDescription = isMetadataBatch
|
||||
? "Recherche auprès des fournisseurs externes pour chaque série"
|
||||
: isThumbnailOnly
|
||||
? undefined
|
||||
: isExtractingPages
|
||||
? "Extraction de la première page de chaque archive (nombre de pages + image brute)"
|
||||
: isThumbnailPhase
|
||||
? "Génération des miniatures pour les livres analysés"
|
||||
: "Scan et indexation des fichiers de la bibliothèque";
|
||||
|
||||
// Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
|
||||
const speedCount = isThumbnailOnly
|
||||
@@ -166,9 +187,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to jobs
|
||||
Retour aux tâches
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">Job Details</h1>
|
||||
<h1 className="text-3xl font-bold text-foreground mt-2">Détails de la tâche</h1>
|
||||
</div>
|
||||
|
||||
{/* Summary banner — completed */}
|
||||
@@ -178,19 +199,24 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-success">
|
||||
<span className="font-semibold">Completed in {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
{job.stats_json && (
|
||||
<span className="font-semibold">Terminé en {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
{isMetadataBatch && batchReport && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warnings`}
|
||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} errors`}
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} thumbnails`}
|
||||
— {batchReport.auto_matched} auto-associées, {batchReport.already_linked} déjà liées, {batchReport.no_results} aucun résultat, {batchReport.errors} erreurs
|
||||
</span>
|
||||
)}
|
||||
{!job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||
{!isMetadataBatch && job.stats_json && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.processed_files ?? job.total_files} thumbnails generated
|
||||
— {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avertissements`}
|
||||
{job.stats_json.errors > 0 && `, ${job.stats_json.errors} erreurs`}
|
||||
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} miniatures`}
|
||||
</span>
|
||||
)}
|
||||
{!isMetadataBatch && !job.stats_json && isThumbnailOnly && job.total_files != null && (
|
||||
<span className="ml-2 text-success/80">
|
||||
— {job.processed_files ?? job.total_files} miniatures générées
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -204,9 +230,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-destructive">
|
||||
<span className="font-semibold">Job failed</span>
|
||||
<span className="font-semibold">Tâche échouée</span>
|
||||
{job.started_at && (
|
||||
<span className="ml-2 text-destructive/80">after {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="ml-2 text-destructive/80">après {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
)}
|
||||
{job.error_opt && (
|
||||
<p className="mt-1 text-destructive/70 font-mono text-xs break-all">{job.error_opt}</p>
|
||||
@@ -222,9 +248,9 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
</svg>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="font-semibold">Cancelled</span>
|
||||
<span className="font-semibold">Annulé</span>
|
||||
{job.started_at && (
|
||||
<span className="ml-2">after {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
<span className="ml-2">après {formatDuration(job.started_at, job.finished_at)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -234,7 +260,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{/* Overview Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Overview</CardTitle>
|
||||
<CardTitle>Aperçu</CardTitle>
|
||||
{typeInfo.description && (
|
||||
<CardDescription>{typeInfo.description}</CardDescription>
|
||||
)}
|
||||
@@ -252,16 +278,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/60">
|
||||
<span className="text-sm text-muted-foreground">Status</span>
|
||||
<span className="text-sm text-muted-foreground">Statut</span>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
<div className={`flex items-center justify-between py-2 ${(job.book_id || job.started_at) ? "border-b border-border/60" : ""}`}>
|
||||
<span className="text-sm text-muted-foreground">Library</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || "All libraries"}</span>
|
||||
<span className="text-sm text-muted-foreground">Bibliothèque</span>
|
||||
<span className="text-sm text-foreground">{job.library_id || "Toutes les bibliothèques"}</span>
|
||||
</div>
|
||||
{job.book_id && (
|
||||
<div className={`flex items-center justify-between py-2 ${job.started_at ? "border-b border-border/60" : ""}`}>
|
||||
<span className="text-sm text-muted-foreground">Book</span>
|
||||
<span className="text-sm text-muted-foreground">Livre</span>
|
||||
<Link
|
||||
href={`/books/${job.book_id}`}
|
||||
className="text-sm text-primary hover:text-primary/80 font-mono hover:underline"
|
||||
@@ -272,7 +298,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
{job.started_at && (
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-muted-foreground">Duration</span>
|
||||
<span className="text-sm text-muted-foreground">Durée</span>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
</span>
|
||||
@@ -284,7 +310,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{/* Timeline Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
<CardTitle>Chronologie</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
@@ -296,7 +322,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-muted border-2 border-border shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Created</span>
|
||||
<span className="text-sm font-medium text-foreground">Créé</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,15 +332,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-primary shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Phase 1 — Discovery</span>
|
||||
<span className="text-sm font-medium text-foreground">Phase 1 — Découverte</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Duration: {formatDuration(job.started_at, job.phase2_started_at)}
|
||||
Durée : {formatDuration(job.started_at, job.phase2_started_at)}
|
||||
{job.stats_json && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
· {job.stats_json.scanned_files} scanned, {job.stats_json.indexed_files} indexed
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} removed`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} warn`}
|
||||
· {job.stats_json.scanned_files} scannés, {job.stats_json.indexed_files} indexés
|
||||
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} supprimés`}
|
||||
{(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} avert.`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -329,12 +355,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
job.generating_thumbnails_started_at || job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Phase 2a — Extracting pages</span>
|
||||
<span className="text-sm font-medium text-foreground">Phase 2a — Extraction des pages</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.phase2_started_at).toLocaleString()}</p>
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Duration: {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)}
|
||||
Durée : {formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null)}
|
||||
{!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
|
||||
<span className="text-muted-foreground font-normal ml-1">· in progress</span>
|
||||
<span className="text-muted-foreground font-normal ml-1">· en cours</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -349,26 +375,26 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isThumbnailOnly ? "Thumbnails" : "Phase 2b — Generating thumbnails"}
|
||||
{isThumbnailOnly ? "Miniatures" : "Phase 2b — Génération des miniatures"}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString()}
|
||||
</p>
|
||||
{(job.generating_thumbnails_started_at || job.finished_at) && (
|
||||
<p className="text-xs text-primary/80 font-medium mt-0.5">
|
||||
Duration: {formatDuration(
|
||||
Durée : {formatDuration(
|
||||
job.generating_thumbnails_started_at ?? job.phase2_started_at!,
|
||||
job.finished_at ?? null
|
||||
)}
|
||||
{job.total_files != null && job.total_files > 0 && (
|
||||
<span className="text-muted-foreground font-normal ml-1">
|
||||
· {job.processed_files ?? job.total_files} thumbnails
|
||||
· {job.processed_files ?? job.total_files} miniatures
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{!job.finished_at && isThumbnailPhase && (
|
||||
<span className="text-xs text-muted-foreground">in progress</span>
|
||||
<span className="text-xs text-muted-foreground">en cours</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,7 +407,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
job.finished_at ? "bg-primary" : "bg-primary animate-pulse"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Started</span>
|
||||
<span className="text-sm font-medium text-foreground">Démarré</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.started_at).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -392,7 +418,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-3.5 h-3.5 rounded-full mt-0.5 bg-warning shrink-0 z-10" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">Waiting to start…</span>
|
||||
<span className="text-sm font-medium text-foreground">En attente de démarrage…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -405,7 +431,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{isCompleted ? "Completed" : isFailed ? "Failed" : "Cancelled"}
|
||||
{isCompleted ? "Terminé" : isFailed ? "Échoué" : "Annulé"}
|
||||
</span>
|
||||
<p className="text-xs text-muted-foreground">{new Date(job.finished_at).toLocaleString()}</p>
|
||||
</div>
|
||||
@@ -430,13 +456,13 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<StatBox
|
||||
value={job.processed_files ?? 0}
|
||||
label={isThumbnailOnly || isPhase2 ? "Generated" : "Processed"}
|
||||
label={isThumbnailOnly || isPhase2 ? "Générés" : "Traités"}
|
||||
variant="primary"
|
||||
/>
|
||||
<StatBox value={job.total_files} label="Total" />
|
||||
<StatBox
|
||||
value={Math.max(0, job.total_files - (job.processed_files ?? 0))}
|
||||
label="Remaining"
|
||||
label="Restants"
|
||||
variant={isCompleted ? "default" : "warning"}
|
||||
/>
|
||||
</div>
|
||||
@@ -444,7 +470,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
{job.current_file && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg">
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">Current file</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wide">Fichier en cours</span>
|
||||
<code className="block mt-1 text-xs font-mono text-foreground break-all">{job.current_file}</code>
|
||||
</div>
|
||||
)}
|
||||
@@ -453,10 +479,10 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
)}
|
||||
|
||||
{/* Index Statistics — index jobs only */}
|
||||
{job.stats_json && !isThumbnailOnly && (
|
||||
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Index statistics</CardTitle>
|
||||
<CardTitle>Statistiques d'indexation</CardTitle>
|
||||
{job.started_at && (
|
||||
<CardDescription>
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
@@ -466,11 +492,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
|
||||
<StatBox value={job.stats_json.scanned_files} label="Scanned" variant="success" />
|
||||
<StatBox value={job.stats_json.indexed_files} label="Indexed" variant="primary" />
|
||||
<StatBox value={job.stats_json.removed_files} label="Removed" variant="warning" />
|
||||
<StatBox value={job.stats_json.warnings ?? 0} label="Warnings" variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||
<StatBox value={job.stats_json.errors} label="Errors" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||
<StatBox value={job.stats_json.scanned_files} label="Scannés" variant="success" />
|
||||
<StatBox value={job.stats_json.indexed_files} label="Indexés" variant="primary" />
|
||||
<StatBox value={job.stats_json.removed_files} label="Supprimés" variant="warning" />
|
||||
<StatBox value={job.stats_json.warnings ?? 0} label="Avertissements" variant={(job.stats_json.warnings ?? 0) > 0 ? "warning" : "default"} />
|
||||
<StatBox value={job.stats_json.errors} label="Erreurs" variant={job.stats_json.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -480,7 +506,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
{isThumbnailOnly && isCompleted && job.total_files != null && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Thumbnail statistics</CardTitle>
|
||||
<CardTitle>Statistiques des miniatures</CardTitle>
|
||||
{job.started_at && (
|
||||
<CardDescription>
|
||||
{formatDuration(job.started_at, job.finished_at)}
|
||||
@@ -490,19 +516,102 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatBox value={job.processed_files ?? job.total_files} label="Generated" variant="success" />
|
||||
<StatBox value={job.processed_files ?? job.total_files} label="Générés" variant="success" />
|
||||
<StatBox value={job.total_files} label="Total" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Metadata batch report */}
|
||||
{isMetadataBatch && batchReport && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rapport du lot</CardTitle>
|
||||
<CardDescription>{batchReport.total_series} séries analysées</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<StatBox value={batchReport.auto_matched} label="Auto-associé" variant="success" />
|
||||
<StatBox value={batchReport.already_linked} label="Déjà lié" variant="primary" />
|
||||
<StatBox value={batchReport.no_results} label="Aucun résultat" />
|
||||
<StatBox value={batchReport.too_many_results} label="Trop de résultats" variant="warning" />
|
||||
<StatBox value={batchReport.low_confidence} label="Confiance faible" variant="warning" />
|
||||
<StatBox value={batchReport.errors} label="Erreurs" variant={batchReport.errors > 0 ? "error" : "default"} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Metadata batch results */}
|
||||
{isMetadataBatch && batchResults.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Résultats par série</CardTitle>
|
||||
<CardDescription>{batchResults.length} séries traitées</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{batchResults.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`p-3 rounded-lg border ${
|
||||
r.status === "auto_matched" ? "bg-success/10 border-success/20" :
|
||||
r.status === "already_linked" ? "bg-primary/10 border-primary/20" :
|
||||
r.status === "error" ? "bg-destructive/10 border-destructive/20" :
|
||||
"bg-muted/50 border-border/60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm text-foreground truncate">{r.series_name}</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap ${
|
||||
r.status === "auto_matched" ? "bg-success/20 text-success" :
|
||||
r.status === "already_linked" ? "bg-primary/20 text-primary" :
|
||||
r.status === "no_results" ? "bg-muted text-muted-foreground" :
|
||||
r.status === "too_many_results" ? "bg-amber-500/15 text-amber-600" :
|
||||
r.status === "low_confidence" ? "bg-amber-500/15 text-amber-600" :
|
||||
r.status === "error" ? "bg-destructive/20 text-destructive" :
|
||||
"bg-muted text-muted-foreground"
|
||||
}`}>
|
||||
{r.status === "auto_matched" ? "Auto-associé" :
|
||||
r.status === "already_linked" ? "Déjà lié" :
|
||||
r.status === "no_results" ? "Aucun résultat" :
|
||||
r.status === "too_many_results" ? "Trop de résultats" :
|
||||
r.status === "low_confidence" ? "Confiance faible" :
|
||||
r.status === "error" ? "Erreur" :
|
||||
r.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
|
||||
{r.provider_used && (
|
||||
<span>{r.provider_used}{r.fallback_used ? " (secours)" : ""}</span>
|
||||
)}
|
||||
{r.candidates_count > 0 && (
|
||||
<span>{r.candidates_count} candidat{r.candidates_count > 1 ? "s" : ""}</span>
|
||||
)}
|
||||
{r.best_confidence != null && (
|
||||
<span>{Math.round(r.best_confidence * 100)}% confiance</span>
|
||||
)}
|
||||
</div>
|
||||
{r.best_candidate_json && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Correspondance : {(r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString()}
|
||||
</p>
|
||||
)}
|
||||
{r.error_message && (
|
||||
<p className="text-xs text-destructive/80 mt-1">{r.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* File errors */}
|
||||
{errors.length > 0 && (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>File errors ({errors.length})</CardTitle>
|
||||
<CardDescription>Errors encountered while processing individual files</CardDescription>
|
||||
<CardTitle>Erreurs de fichiers ({errors.length})</CardTitle>
|
||||
<CardDescription>Erreurs rencontrées lors du traitement des fichiers</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{errors.map((error) => (
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, IndexJobDto, LibraryDto } from "../../lib/api";
|
||||
import { JobsList } from "../components/JobsList";
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -47,6 +47,15 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
async function triggerMetadataBatch(formData: FormData) {
|
||||
"use server";
|
||||
const libraryId = formData.get("library_id") as string;
|
||||
if (!libraryId) return;
|
||||
const result = await startMetadataBatch(libraryId);
|
||||
revalidatePath("/jobs");
|
||||
redirect(`/jobs?highlight=${result.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
@@ -54,20 +63,21 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<svg className="w-8 h-8 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Index Jobs
|
||||
Tâches d'indexation
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Queue New Job</CardTitle>
|
||||
<CardTitle>Lancer une tâche</CardTitle>
|
||||
<CardDescription>Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<FormRow>
|
||||
<FormField className="flex-1 max-w-xs">
|
||||
<FormSelect name="library_id" defaultValue="">
|
||||
<option value="">All libraries</option>
|
||||
<option value="">Toutes les bibliothèques</option>
|
||||
{libraries.map((lib) => (
|
||||
<option key={lib.id} value={lib.id}>{lib.name}</option>
|
||||
))}
|
||||
@@ -78,25 +88,31 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Rebuild
|
||||
Reconstruction
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerFullRebuild} variant="warning">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Full Rebuild
|
||||
Reconstruction complète
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerThumbnailsRebuild} variant="secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Generate thumbnails
|
||||
Générer les miniatures
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerThumbnailsRegenerate} variant="warning">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Regenerate thumbnails
|
||||
Regénérer les miniatures
|
||||
</Button>
|
||||
<Button type="submit" formAction={triggerMetadataBatch} variant="secondary">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
Métadonnées en lot
|
||||
</Button>
|
||||
</div>
|
||||
</FormRow>
|
||||
@@ -104,6 +120,82 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Job types legend */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Référence des types de tâches</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Reconstruction</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C’est l’action la plus courante et la plus rapide.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Reconstruction complète</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Générer les miniatures</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Génère les miniatures uniquement pour les livres qui n’en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Regénérer les miniatures</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="shrink-0 mt-0.5">
|
||||
<svg className="w-5 h-5 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-foreground">Métadonnées en lot</span>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">
|
||||
Recherche automatiquement les métadonnées de chaque série de la bibliothèque auprès du provider configuré (avec fallback si configuré). Seuls les résultats avec un match unique à 100% de confiance sont appliqués automatiquement. Les séries déjà liées sont ignorées. Un rapport détaillé par série est disponible à la fin du job. <strong>Requiert une bibliothèque spécifique</strong> (ne fonctionne pas sur « Toutes les bibliothèques »).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<JobsList
|
||||
initialJobs={jobs}
|
||||
libraries={libraryMap}
|
||||
|
||||
Reference in New Issue
Block a user