Files
stripstream-librarian/apps/backoffice/app/jobs/page.tsx
Froidefond Julien b955c2697c 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>
2026-03-18 18:26:44 +01:00

207 lines
12 KiB
TypeScript

import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, IndexJobDto, LibraryDto } from "../../lib/api";
import { JobsList } from "../components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
export const dynamic = "force-dynamic";
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ highlight?: string }> }) {
const { highlight } = await searchParams;
const [jobs, libraries] = await Promise.all([
listJobs().catch(() => [] as IndexJobDto[]),
fetchLibraries().catch(() => [] as LibraryDto[])
]);
const libraryMap = new Map(libraries.map(l => [l.id, l.name]));
async function triggerRebuild(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await rebuildIndex(libraryId || undefined);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerFullRebuild(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await rebuildIndex(libraryId || undefined, true);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerThumbnailsRebuild(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await rebuildThumbnails(libraryId || undefined);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerThumbnailsRegenerate(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
const result = await regenerateThumbnails(libraryId || undefined);
revalidatePath("/jobs");
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">
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
<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>
Tâches d&apos;indexation
</h1>
</div>
<Card className="mb-6">
<CardHeader>
<CardTitle>Lancer une tâche</CardTitle>
<CardDescription>Sélectionnez une bibliothèque (ou toutes) et choisissez l&apos;action à effectuer.</CardDescription>
</CardHeader>
<CardContent>
<form>
<FormRow>
<FormField className="flex-1 max-w-xs">
<FormSelect name="library_id" defaultValue="">
<option value="">Toutes les bibliothèques</option>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>{lib.name}</option>
))}
</FormSelect>
</FormField>
<div className="flex flex-wrap gap-2">
<Button type="submit" formAction={triggerRebuild}>
<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>
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>
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>
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>
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>
</form>
</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&eacute;mental : d&eacute;tecte les fichiers ajout&eacute;s, modifi&eacute;s ou supprim&eacute;s depuis le dernier scan, les indexe et g&eacute;n&egrave;re les miniatures manquantes. Les donn&eacute;es existantes non modifi&eacute;es sont conserv&eacute;es. C&rsquo;est l&rsquo;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&eacute;es index&eacute;es (livres, s&eacute;ries, miniatures) puis effectue un scan complet depuis z&eacute;ro. Utile si la base de donn&eacute;es est d&eacute;synchronis&eacute;e ou corrompue. Op&eacute;ration longue et destructive : les statuts de lecture et les m&eacute;tadonn&eacute;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&eacute;n&egrave;re les miniatures uniquement pour les livres qui n&rsquo;en ont pas encore. Les miniatures existantes ne sont pas touch&eacute;es. Utile apr&egrave;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&eacute;n&egrave;re toutes les miniatures depuis z&eacute;ro, en rempla&ccedil;ant les existantes. Utile si la qualit&eacute; ou la taille des miniatures a chang&eacute; 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&eacute;tadonn&eacute;es de chaque s&eacute;rie de la biblioth&egrave;que aupr&egrave;s du provider configur&eacute; (avec fallback si configur&eacute;). Seuls les r&eacute;sultats avec un match unique &agrave; 100% de confiance sont appliqu&eacute;s automatiquement. Les s&eacute;ries d&eacute;j&agrave; li&eacute;es sont ignor&eacute;es. Un rapport d&eacute;taill&eacute; par s&eacute;rie est disponible &agrave; la fin du job. <strong>Requiert une biblioth&egrave;que sp&eacute;cifique</strong> (ne fonctionne pas sur &laquo; Toutes les bibliothèques &raquo;).
</p>
</div>
</div>
</div>
</CardContent>
</Card>
<JobsList
initialJobs={jobs}
libraries={libraryMap}
highlightJobId={highlight}
/>
</>
);
}