feat: add metadata refresh job to re-download metadata for linked series

Adds a new job type that refreshes metadata from external providers for
all series already linked via approved external_metadata_links. Tracks
and displays per-field diffs (series and book level), respects locked
fields, and provides a detailed change report in the job detail page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-19 09:09:10 +01:00
parent 818bd82e0f
commit 163dc3698c
17 changed files with 1170 additions and 56 deletions

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function GET(request: NextRequest) {
try {
const jobId = request.nextUrl.searchParams.get("job_id");
if (!jobId) {
return NextResponse.json({ error: "job_id required" }, { status: 400 });
}
const data = await apiFetch(`/metadata/refresh/${jobId}/report`);
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to get report";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { apiFetch } from "@/lib/api";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const data = await apiFetch<{ id: string; status: string }>("/metadata/refresh", {
method: "POST",
body: JSON.stringify(body),
});
return NextResponse.json(data);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to start refresh";
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { useTranslation } from "../../lib/i18n/context";
import { JobProgress } from "./JobProgress";
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar } from "./ui";
import { StatusBadge, JobTypeBadge, Button, MiniProgressBar, Icon } from "./ui";
interface JobRowProps {
job: {
@@ -59,28 +59,11 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
const isThumbnailJob = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
const hasThumbnailPhase = isPhase2 || isThumbnailJob;
// Files column: index-phase stats only (Phase 1 discovery)
const filesDisplay =
job.status === "running" && !isPhase2
? job.total_files != null
? `${job.processed_files ?? 0}/${job.total_files}`
: scanned > 0
? t("jobRow.scanned", { count: scanned })
: "-"
: job.status === "success" && (indexed > 0 || removed > 0 || errors > 0)
? null // rendered below as ✓ / / ⚠
: scanned > 0
? t("jobRow.scanned", { count: scanned })
: "—";
const isMetadataBatch = job.type === "metadata_batch";
const isMetadataRefresh = job.type === "metadata_refresh";
// Thumbnails column (Phase 2: extracting_pages + generating_thumbnails)
// Thumbnails progress (Phase 2: extracting_pages + generating_thumbnails)
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isPhase2);
const thumbDisplay =
thumbInProgress && job.total_files != null
? `${job.processed_files ?? 0}/${job.total_files}`
: job.status === "success" && job.total_files != null && hasThumbnailPhase
? `${job.total_files}`
: "—";
return (
<>
@@ -122,25 +105,67 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
{filesDisplay !== null ? (
<span className="text-sm text-foreground">{filesDisplay}</span>
) : (
<div className="flex items-center gap-2 text-xs">
<span className="text-success"> {indexed}</span>
{removed > 0 && <span className="text-warning"> {removed}</span>}
{errors > 0 && <span className="text-error"> {errors}</span>}
{/* Running progress */}
{isActive && job.total_files != null && (
<div className="flex flex-col gap-1">
<span className="text-sm text-foreground">{job.processed_files ?? 0}/{job.total_files}</span>
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
</div>
)}
{job.status === "running" && !isPhase2 && job.total_files != null && (
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
)}
</div>
</td>
<td className="px-4 py-3">
<div className="flex flex-col gap-1">
<span className="text-sm text-foreground">{thumbDisplay}</span>
{thumbInProgress && job.total_files != null && (
<MiniProgressBar value={job.processed_files ?? 0} max={job.total_files} className="w-24" />
{/* Completed stats with icons */}
{!isActive && (
<div className="flex items-center gap-3 text-xs">
{/* Files: indexed count */}
{indexed > 0 && (
<span className="inline-flex items-center gap-1 text-success" title={t("jobRow.filesIndexed", { count: indexed })}>
<Icon name="document" size="sm" />
{indexed}
</span>
)}
{/* Removed files */}
{removed > 0 && (
<span className="inline-flex items-center gap-1 text-warning" title={t("jobRow.filesRemoved", { count: removed })}>
<Icon name="trash" size="sm" />
{removed}
</span>
)}
{/* Thumbnails */}
{hasThumbnailPhase && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-primary" title={t("jobRow.thumbnailsGenerated", { count: job.total_files })}>
<Icon name="image" size="sm" />
{job.total_files}
</span>
)}
{/* Metadata batch: series processed */}
{isMetadataBatch && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataProcessed", { count: job.total_files })}>
<Icon name="tag" size="sm" />
{job.total_files}
</span>
)}
{/* Metadata refresh: links refreshed */}
{isMetadataRefresh && job.total_files != null && job.total_files > 0 && (
<span className="inline-flex items-center gap-1 text-info" title={t("jobRow.metadataRefreshed", { count: job.total_files })}>
<Icon name="tag" size="sm" />
{job.total_files}
</span>
)}
{/* Errors */}
{errors > 0 && (
<span className="inline-flex items-center gap-1 text-error" title={t("jobRow.errors", { count: errors })}>
<Icon name="warning" size="sm" />
{errors}
</span>
)}
{/* Scanned only (no other stats) */}
{indexed === 0 && removed === 0 && errors === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && scanned > 0 && (
<span className="text-sm text-muted-foreground">{t("jobRow.scanned", { count: scanned })}</span>
)}
{/* Nothing to show */}
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
)}
</div>
</td>
@@ -172,7 +197,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, formatDate, fo
</tr>
{showProgress && isActive && (
<tr>
<td colSpan={9} className="px-4 py-3 bg-muted/50">
<td colSpan={8} className="px-4 py-3 bg-muted/50">
<JobProgress
jobId={job.id}
onComplete={handleComplete}

View File

@@ -117,8 +117,7 @@ export function JobsList({ initialJobs, libraries, highlightJobId }: JobsListPro
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.library")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.type")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.status")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.files")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.thumbnails")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.stats")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.duration")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.created")}</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider">{t("jobsList.actions")}</th>

View File

@@ -114,6 +114,7 @@ export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) {
thumbnail_regenerate: t("jobType.thumbnail_regenerate"),
cbr_to_cbz: t("jobType.cbr_to_cbz"),
metadata_batch: t("jobType.metadata_batch"),
metadata_refresh: t("jobType.metadata_refresh"),
};
const label = jobTypeLabels[key] ?? type;
return <Badge variant={variant} className={className}>{label}</Badge>;

View File

@@ -31,7 +31,9 @@ type IconName =
| "play"
| "stop"
| "spinner"
| "warning";
| "warning"
| "tag"
| "document";
type IconSize = "sm" | "md" | "lg" | "xl";
@@ -82,6 +84,8 @@ const icons: Record<IconName, string> = {
stop: "M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z",
spinner: "M4 4v5h.582m15.582 0A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15",
warning: "M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z",
tag: "M7 7h.01M7 3h5a1.99 1.99 0 011.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z",
document: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z",
};
const colorClasses: Partial<Record<IconName, string>> = {

View File

@@ -1,6 +1,6 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, MetadataBatchReportDto, MetadataBatchResultDto } from "../../../lib/api";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "../../../lib/api";
import {
Card, CardHeader, CardTitle, CardDescription, CardContent,
StatusBadge, JobTypeBadge, StatBox, ProgressBar
@@ -119,9 +119,15 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
description: t("jobType.metadata_batchDesc"),
isThumbnailOnly: false,
},
metadata_refresh: {
label: t("jobType.metadata_refreshLabel"),
description: t("jobType.metadata_refreshDesc"),
isThumbnailOnly: false,
},
};
const isMetadataBatch = job.type === "metadata_batch";
const isMetadataRefresh = job.type === "metadata_refresh";
// Fetch batch report & results for metadata_batch jobs
let batchReport: MetadataBatchReportDto | null = null;
@@ -133,6 +139,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
]);
}
// Fetch refresh report for metadata_refresh jobs
let refreshReport: MetadataRefreshReportDto | null = null;
if (isMetadataRefresh) {
refreshReport = await getMetadataRefreshReport(id).catch(() => null);
}
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
label: job.type,
description: null,
@@ -154,6 +166,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
// Which label to use for the progress card
const progressTitle = isMetadataBatch
? t("jobDetail.metadataSearch")
: isMetadataRefresh
? t("jobDetail.metadataRefresh")
: isThumbnailOnly
? t("jobType.thumbnail_rebuild")
: isExtractingPages
@@ -164,6 +178,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
const progressDescription = isMetadataBatch
? t("jobDetail.metadataSearchDesc")
: isMetadataRefresh
? t("jobDetail.metadataRefreshDesc")
: isThumbnailOnly
? undefined
: isExtractingPages
@@ -209,7 +225,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
{batchReport.auto_matched} {t("jobDetail.autoMatched").toLowerCase()}, {batchReport.already_linked} {t("jobDetail.alreadyLinked").toLowerCase()}, {batchReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {batchReport.errors} {t("jobDetail.errors").toLowerCase()}
</span>
)}
{!isMetadataBatch && job.stats_json && (
{isMetadataRefresh && refreshReport && (
<span className="ml-2 text-success/80">
{refreshReport.refreshed} {t("jobDetail.refreshed").toLowerCase()}, {refreshReport.unchanged} {t("jobDetail.unchanged").toLowerCase()}, {refreshReport.errors} {t("jobDetail.errors").toLowerCase()}
</span>
)}
{!isMetadataBatch && !isMetadataRefresh && job.stats_json && (
<span className="ml-2 text-success/80">
{job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
@@ -218,7 +239,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
{job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
</span>
)}
{!isMetadataBatch && !job.stats_json && isThumbnailOnly && job.total_files != null && (
{!isMetadataBatch && !isMetadataRefresh && !job.stats_json && isThumbnailOnly && job.total_files != null && (
<span className="ml-2 text-success/80">
{job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
</span>
@@ -483,7 +504,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
)}
{/* Index Statistics — index jobs only */}
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && (
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.indexStats")}</CardTitle>
@@ -547,6 +568,132 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</Card>
)}
{/* Metadata refresh report */}
{isMetadataRefresh && refreshReport && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.refreshReport")}</CardTitle>
<CardDescription>{t("jobDetail.refreshReportDesc", { count: String(refreshReport.total_links) })}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatBox value={refreshReport.refreshed} label={t("jobDetail.refreshed")} variant="success" />
<StatBox value={refreshReport.unchanged} label={t("jobDetail.unchanged")} />
<StatBox value={refreshReport.errors} label={t("jobDetail.errors")} variant={refreshReport.errors > 0 ? "error" : "default"} />
<StatBox value={refreshReport.total_links} label={t("jobDetail.total")} />
</div>
</CardContent>
</Card>
)}
{/* Metadata refresh changes detail */}
{isMetadataRefresh && refreshReport && refreshReport.changes.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>{t("jobDetail.refreshChanges")}</CardTitle>
<CardDescription>{t("jobDetail.refreshChangesDesc", { count: String(refreshReport.changes.length) })}</CardDescription>
</CardHeader>
<CardContent className="space-y-3 max-h-[600px] overflow-y-auto">
{refreshReport.changes.map((r, idx) => (
<div
key={idx}
className={`p-3 rounded-lg border ${
r.status === "updated" ? "bg-success/10 border-success/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">
{job.library_id ? (
<Link
href={`/libraries/${job.library_id}/series/${encodeURIComponent(r.series_name)}`}
className="font-medium text-sm text-primary hover:underline truncate"
>
{r.series_name}
</Link>
) : (
<span className="font-medium text-sm text-foreground truncate">{r.series_name}</span>
)}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">{r.provider}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap ${
r.status === "updated" ? "bg-success/20 text-success" :
r.status === "error" ? "bg-destructive/20 text-destructive" :
"bg-muted text-muted-foreground"
}`}>
{r.status === "updated" ? t("jobDetail.refreshed") :
r.status === "error" ? t("common.error") :
t("jobDetail.unchanged")}
</span>
</div>
</div>
{r.error && (
<p className="text-xs text-destructive/80 mt-1">{r.error}</p>
)}
{/* Series field changes */}
{r.series_changes.length > 0 && (
<div className="mt-2">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground font-semibold">{t("metadata.seriesLabel")}</span>
<div className="mt-1 space-y-1">
{r.series_changes.map((c, ci) => (
<div key={ci} className="flex items-start gap-2 text-xs">
<span className="font-medium text-foreground shrink-0 w-24">{t(`field.${c.field}` as never) || c.field}</span>
<span className="text-muted-foreground line-through truncate max-w-[200px]" title={String(c.old ?? "—")}>
{c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old)) : "—"}
</span>
<span className="text-success shrink-0"></span>
<span className="text-success truncate max-w-[200px]" title={String(c.new ?? "—")}>
{c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new)) : "—"}
</span>
</div>
))}
</div>
</div>
)}
{/* Book field changes */}
{r.book_changes.length > 0 && (
<div className="mt-2">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground font-semibold">
{t("metadata.booksLabel")} ({r.book_changes.length})
</span>
<div className="mt-1 space-y-2">
{r.book_changes.map((b, bi) => (
<div key={bi} className="pl-2 border-l-2 border-border/60">
<Link
href={`/books/${b.book_id}`}
className="text-xs text-primary hover:underline font-medium"
>
{b.volume != null && <span className="text-muted-foreground mr-1">T.{b.volume}</span>}
{b.title}
</Link>
<div className="mt-0.5 space-y-0.5">
{b.changes.map((c, ci) => (
<div key={ci} className="flex items-start gap-2 text-xs">
<span className="font-medium text-foreground shrink-0 w-24">{t(`field.${c.field}` as never) || c.field}</span>
<span className="text-muted-foreground line-through truncate max-w-[150px]" title={String(c.old ?? "—")}>
{c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old).substring(0, 60)) : "—"}
</span>
<span className="text-success shrink-0"></span>
<span className="text-success truncate max-w-[150px]" title={String(c.new ?? "—")}>
{c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new).substring(0, 60)) : "—"}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* Metadata batch results */}
{isMetadataBatch && batchResults.length > 0 && (
<Card className="lg:col-span-2">

View File

@@ -1,6 +1,6 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, IndexJobDto, LibraryDto } from "../../lib/api";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, IndexJobDto, LibraryDto } from "../../lib/api";
import { JobsList } from "../components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormSelect, FormRow } from "../components/ui";
import { getServerTranslations } from "../../lib/i18n/server";
@@ -58,6 +58,15 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
redirect(`/jobs?highlight=${result.id}`);
}
async function triggerMetadataRefresh(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
if (!libraryId) return;
const result = await startMetadataRefresh(libraryId);
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
}
return (
<>
<div className="mb-6">
@@ -116,6 +125,12 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
</svg>
{t("jobs.batchMetadata")}
</Button>
<Button type="submit" formAction={triggerMetadataRefresh} 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 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>
{t("jobs.refreshMetadata")}
</Button>
</div>
</FormRow>
</form>
@@ -184,6 +199,17 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.batchMetadataDescription") }} />
</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 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">{t("jobs.refreshMetadata")}</span>
<p className="text-muted-foreground text-xs mt-0.5" dangerouslySetInnerHTML={{ __html: t("jobs.refreshMetadataDescription") }} />
</div>
</div>
</div>
</CardContent>
</Card>