diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/DownloadDetectionCards.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/DownloadDetectionCards.tsx
new file mode 100644
index 0000000..d61979c
--- /dev/null
+++ b/apps/backoffice/app/(app)/jobs/[id]/components/DownloadDetectionCards.tsx
@@ -0,0 +1,97 @@
+import Link from "next/link";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatBox } from "@/app/components/ui";
+import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton";
+import type { DownloadDetectionReportDto, DownloadDetectionResultDto } from "@/lib/api";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
+
+export function DownloadDetectionReportCard({ report, t }: { report: DownloadDetectionReportDto; t: TranslateFunction }) {
+ return (
+
+
+ {t("jobDetail.downloadDetectionReport")}
+ {t("jobDetail.seriesAnalyzed", { count: String(report.total_series) })}
+
+
+
+
+
+
+
+ 0 ? "error" : "default"} />
+
+
+
+ );
+}
+
+export function DownloadDetectionResultsCard({ results, libraryId, t }: {
+ results: DownloadDetectionResultDto[];
+ libraryId: string | null;
+ t: TranslateFunction;
+}) {
+ if (results.length === 0) return null;
+
+ return (
+
+
+
+ {t("jobDetail.downloadAvailableReleases")}
+ {t("jobDetail.downloadAvailableReleasesDesc", { count: String(results.length) })}
+
+
+ {results.map((r) => (
+
+
+ {libraryId ? (
+
+ {r.series_name}
+
+ ) : (
+ {r.series_name}
+ )}
+
+ {t("jobDetail.downloadMissingCount", { count: String(r.missing_count) })}
+
+
+ {r.available_releases && r.available_releases.length > 0 && (
+
+ {r.available_releases.map((release, idx) => (
+
+
+
{release.title}
+
+ {release.indexer && (
+
{release.indexer}
+ )}
+ {release.seeders != null && (
+
{release.seeders} {t("prowlarr.columnSeeders").toLowerCase()}
+ )}
+
+ {(release.size / 1024 / 1024).toFixed(0)} MB
+
+
+ {release.matched_missing_volumes.map((vol) => (
+
+ T.{vol}
+
+ ))}
+
+
+
+ {release.download_url && (
+
+ )}
+
+ ))}
+
+ )}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/JobErrorsCard.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/JobErrorsCard.tsx
new file mode 100644
index 0000000..9fcc149
--- /dev/null
+++ b/apps/backoffice/app/(app)/jobs/[id]/components/JobErrorsCard.tsx
@@ -0,0 +1,31 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/app/components/ui";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
+
+interface JobError {
+ id: string;
+ file_path: string;
+ error_message: string;
+ created_at: string;
+}
+
+export function JobErrorsCard({ errors, t, locale }: { errors: JobError[]; t: TranslateFunction; locale: string }) {
+ if (errors.length === 0) return null;
+
+ return (
+
+
+ {t("jobDetail.fileErrors", { count: String(errors.length) })}
+ {t("jobDetail.fileErrorsDesc")}
+
+
+ {errors.map((error) => (
+
+
{error.file_path}
+
{error.error_message}
+
{new Date(error.created_at).toLocaleString(locale)}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/JobOverviewCard.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/JobOverviewCard.tsx
new file mode 100644
index 0000000..82e45e1
--- /dev/null
+++ b/apps/backoffice/app/(app)/jobs/[id]/components/JobOverviewCard.tsx
@@ -0,0 +1,71 @@
+import Link from "next/link";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatusBadge, JobTypeBadge } from "@/app/components/ui";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
+
+interface JobOverviewCardProps {
+ job: {
+ id: string;
+ type: string;
+ status: string;
+ library_id: string | null;
+ book_id: string | null;
+ started_at: string | null;
+ finished_at: string | null;
+ };
+ typeInfo: { label: string; description: string | null };
+ t: TranslateFunction;
+ formatDuration: (start: string, end: string | null) => string;
+}
+
+export function JobOverviewCard({ job, typeInfo, t, formatDuration }: JobOverviewCardProps) {
+ return (
+
+
+ {t("jobDetail.overview")}
+ {typeInfo.description && (
+ {typeInfo.description}
+ )}
+
+
+
+ ID
+ {job.id}
+
+
+
{t("jobsList.type")}
+
+
+ {typeInfo.label}
+
+
+
+ {t("jobsList.status")}
+
+
+
+ {t("jobDetail.library")}
+ {job.library_id || t("jobDetail.allLibraries")}
+
+ {job.book_id && (
+
+ {t("jobDetail.book")}
+
+ {job.book_id.slice(0, 8)}…
+
+
+ )}
+ {job.started_at && (
+
+ {t("jobsList.duration")}
+
+ {formatDuration(job.started_at, job.finished_at)}
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/JobProgressCard.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/JobProgressCard.tsx
new file mode 100644
index 0000000..5672614
--- /dev/null
+++ b/apps/backoffice/app/(app)/jobs/[id]/components/JobProgressCard.tsx
@@ -0,0 +1,141 @@
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatBox, ProgressBar } from "@/app/components/ui";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
+
+interface JobProgressCardProps {
+ job: {
+ type: string;
+ status: string;
+ started_at: string | null;
+ finished_at: string | null;
+ total_files: number | null;
+ processed_files: number | null;
+ progress_percent: number | null;
+ current_file: string | null;
+ stats_json: {
+ scanned_files: number;
+ indexed_files: number;
+ removed_files: number;
+ errors: number;
+ warnings: number;
+ } | null;
+ };
+ isThumbnailOnly: boolean;
+ progressTitle: string;
+ progressDescription: string | undefined;
+ t: TranslateFunction;
+ formatDuration: (start: string, end: string | null) => string;
+ formatSpeed: (count: number, durationMs: number) => string;
+}
+
+export function JobProgressCard({ job, isThumbnailOnly, progressTitle, progressDescription, t }: JobProgressCardProps) {
+ const isCompleted = job.status === "success";
+ const isPhase2 = job.status === "extracting_pages" || job.status === "generating_thumbnails";
+
+ const showProgressCard =
+ (isCompleted || job.status === "failed" || job.status === "running" || isPhase2) &&
+ (job.total_files != null || !!job.current_file);
+
+ if (!showProgressCard) return null;
+
+ return (
+
+
+ {progressTitle}
+ {progressDescription && {progressDescription}}
+
+
+ {job.total_files != null && job.total_files > 0 && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ {job.current_file && (
+
+ {t("jobDetail.currentFile")}
+ {job.current_file}
+
+ )}
+
+
+ );
+}
+
+export function IndexStatsCard({ job, t, formatDuration, formatSpeed, durationMs }: {
+ job: JobProgressCardProps["job"];
+ t: TranslateFunction;
+ formatDuration: (start: string, end: string | null) => string;
+ formatSpeed: (count: number, durationMs: number) => string;
+ durationMs: number;
+}) {
+ if (!job.stats_json) return null;
+
+ const speedCount = job.stats_json.scanned_files;
+
+ return (
+
+
+ {t("jobDetail.indexStats")}
+ {job.started_at && (
+
+ {formatDuration(job.started_at, job.finished_at)}
+ {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} scan rate`}
+
+ )}
+
+
+
+
+
+
+ 0 ? "warning" : "default"} />
+ 0 ? "error" : "default"} />
+
+
+
+ );
+}
+
+export function ThumbnailStatsCard({ job, t, formatDuration, formatSpeed, durationMs }: {
+ job: JobProgressCardProps["job"];
+ t: TranslateFunction;
+ formatDuration: (start: string, end: string | null) => string;
+ formatSpeed: (count: number, durationMs: number) => string;
+ durationMs: number;
+}) {
+ if (job.total_files == null) return null;
+
+ const speedCount = job.processed_files ?? 0;
+
+ return (
+
+
+ {t("jobDetail.thumbnailStats")}
+ {job.started_at && (
+
+ {formatDuration(job.started_at, job.finished_at)}
+ {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} thumbnails/s`}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/JobSummaryBanner.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/JobSummaryBanner.tsx
new file mode 100644
index 0000000..4bc3a15
--- /dev/null
+++ b/apps/backoffice/app/(app)/jobs/[id]/components/JobSummaryBanner.tsx
@@ -0,0 +1,131 @@
+import type { MetadataBatchReportDto, MetadataRefreshReportDto, ReadingStatusMatchReportDto, ReadingStatusPushReportDto, DownloadDetectionReportDto } from "@/lib/api";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
+
+interface JobSummaryBannerProps {
+ job: {
+ type: string;
+ status: string;
+ started_at: string | null;
+ finished_at: string | null;
+ error_opt: string | null;
+ stats_json: {
+ scanned_files: number;
+ indexed_files: number;
+ removed_files: number;
+ errors: number;
+ warnings: number;
+ } | null;
+ total_files: number | null;
+ processed_files: number | null;
+ };
+ batchReport: MetadataBatchReportDto | null;
+ refreshReport: MetadataRefreshReportDto | null;
+ readingStatusReport: ReadingStatusMatchReportDto | null;
+ readingStatusPushReport: ReadingStatusPushReportDto | null;
+ downloadDetectionReport: DownloadDetectionReportDto | null;
+ t: TranslateFunction;
+ formatDuration: (start: string, end: string | null) => string;
+}
+
+export function JobSummaryBanner({
+ job, batchReport, refreshReport, readingStatusReport, readingStatusPushReport, downloadDetectionReport, t, formatDuration,
+}: JobSummaryBannerProps) {
+ const isCompleted = job.status === "success";
+ const isFailed = job.status === "failed";
+ const isCancelled = job.status === "cancelled";
+ const isMetadataBatch = job.type === "metadata_batch";
+ const isMetadataRefresh = job.type === "metadata_refresh";
+ const isReadingStatusMatch = job.type === "reading_status_match";
+ const isReadingStatusPush = job.type === "reading_status_push";
+ const isDownloadDetection = job.type === "download_detection";
+ const isThumbnailOnly = job.type === "thumbnail_rebuild" || job.type === "thumbnail_regenerate";
+
+ if (isCompleted && job.started_at) {
+ return (
+
+
+
+ {t("jobDetail.completedIn", { duration: formatDuration(job.started_at, job.finished_at) })}
+ {isMetadataBatch && batchReport && (
+
+ — {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()}
+
+ )}
+ {isMetadataRefresh && refreshReport && (
+
+ — {refreshReport.refreshed} {t("jobDetail.refreshed").toLowerCase()}, {refreshReport.unchanged} {t("jobDetail.unchanged").toLowerCase()}, {refreshReport.errors} {t("jobDetail.errors").toLowerCase()}
+
+ )}
+ {isReadingStatusMatch && readingStatusReport && (
+
+ — {readingStatusReport.linked} {t("jobDetail.linked").toLowerCase()}, {readingStatusReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {readingStatusReport.ambiguous} {t("jobDetail.ambiguous").toLowerCase()}, {readingStatusReport.errors} {t("jobDetail.errors").toLowerCase()}
+
+ )}
+ {isReadingStatusPush && readingStatusPushReport && (
+
+ — {readingStatusPushReport.pushed} {t("jobDetail.pushed").toLowerCase()}, {readingStatusPushReport.no_books} {t("jobDetail.noBooks").toLowerCase()}, {readingStatusPushReport.errors} {t("jobDetail.errors").toLowerCase()}
+
+ )}
+ {isDownloadDetection && downloadDetectionReport && (
+
+ — {downloadDetectionReport.found} {t("jobDetail.downloadFound").toLowerCase()}, {downloadDetectionReport.not_found} {t("jobDetail.downloadNotFound").toLowerCase()}, {downloadDetectionReport.errors} {t("jobDetail.errors").toLowerCase()}
+
+ )}
+ {!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !isDownloadDetection && job.stats_json && (
+
+ — {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()}`}
+ {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
+ {job.stats_json.errors > 0 && `, ${job.stats_json.errors} ${t("jobDetail.errors").toLowerCase()}`}
+ {job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
+
+ )}
+ {!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !job.stats_json && isThumbnailOnly && job.total_files != null && (
+
+ — {job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
+
+ )}
+
+
+ );
+ }
+
+ if (isFailed) {
+ return (
+
+
+
+
{t("jobDetail.jobFailed")}
+ {job.started_at && (
+
{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}
+ )}
+ {job.error_opt && (
+
{job.error_opt}
+ )}
+
+
+ );
+ }
+
+ if (isCancelled) {
+ return (
+
+
+
+ {t("jobDetail.cancelled")}
+ {job.started_at && (
+ {t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}
+ )}
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/JobTimelineCard.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/JobTimelineCard.tsx
new file mode 100644
index 0000000..0c96248
--- /dev/null
+++ b/apps/backoffice/app/(app)/jobs/[id]/components/JobTimelineCard.tsx
@@ -0,0 +1,167 @@
+import { Card, CardHeader, CardTitle, CardContent } from "@/app/components/ui";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
+
+interface JobTimelineCardProps {
+ job: {
+ type: string;
+ status: string;
+ created_at: string;
+ started_at: string | null;
+ finished_at: string | null;
+ phase2_started_at: string | null;
+ generating_thumbnails_started_at: string | null;
+ stats_json: {
+ scanned_files: number;
+ indexed_files: number;
+ removed_files: number;
+ warnings: number;
+ } | null;
+ total_files: number | null;
+ processed_files: number | null;
+ };
+ isThumbnailOnly: boolean;
+ t: TranslateFunction;
+ locale: string;
+ formatDuration: (start: string, end: string | null) => string;
+}
+
+export function JobTimelineCard({ job, isThumbnailOnly, t, locale, formatDuration }: JobTimelineCardProps) {
+ const isCompleted = job.status === "success";
+ const isFailed = job.status === "failed";
+ const isExtractingPages = job.status === "extracting_pages";
+ const isThumbnailPhase = job.status === "generating_thumbnails";
+
+ return (
+
+
+ {t("jobDetail.timeline")}
+
+
+
+
+
+ {/* Created */}
+
+
+
+
{t("jobDetail.created")}
+
{new Date(job.created_at).toLocaleString(locale)}
+
+
+
+ {/* Phase 1 start */}
+ {job.started_at && job.phase2_started_at && (
+
+
+
+
{t("jobDetail.phase1")}
+
{new Date(job.started_at).toLocaleString(locale)}
+
+ {t("jobDetail.duration", { duration: formatDuration(job.started_at, job.phase2_started_at) })}
+ {job.stats_json && (
+
+ · {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()}`}
+ {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
+
+ )}
+
+
+
+ )}
+
+ {/* Phase 2a — Extracting pages */}
+ {job.phase2_started_at && !isThumbnailOnly && (
+
+
+
+
{t("jobDetail.phase2a")}
+
{new Date(job.phase2_started_at).toLocaleString(locale)}
+
+ {t("jobDetail.duration", { duration: formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null) })}
+ {!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
+ · {t("jobDetail.inProgress")}
+ )}
+
+
+
+ )}
+
+ {/* Phase 2b — Generating thumbnails */}
+ {(job.generating_thumbnails_started_at || (job.phase2_started_at && isThumbnailOnly)) && (
+
+
+
+
+ {isThumbnailOnly ? t("jobType.thumbnail_rebuild") : t("jobDetail.phase2b")}
+
+
+ {(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString(locale)}
+
+ {(job.generating_thumbnails_started_at || job.finished_at) && (
+
+ {t("jobDetail.duration", { duration: formatDuration(
+ job.generating_thumbnails_started_at ?? job.phase2_started_at!,
+ job.finished_at ?? null
+ ) })}
+ {job.total_files != null && job.total_files > 0 && (
+
+ · {job.processed_files ?? job.total_files} {t("jobType.thumbnail_rebuild").toLowerCase()}
+
+ )}
+
+ )}
+ {!job.finished_at && isThumbnailPhase && (
+
{t("jobDetail.inProgress")}
+ )}
+
+
+ )}
+
+ {/* Started — for jobs without phase2 */}
+ {job.started_at && !job.phase2_started_at && (
+
+
+
+
{t("jobDetail.started")}
+
{new Date(job.started_at).toLocaleString(locale)}
+
+
+ )}
+
+ {/* Pending */}
+ {!job.started_at && (
+
+
+
+ {t("jobDetail.pendingStart")}
+
+
+ )}
+
+ {/* Finished */}
+ {job.finished_at && (
+
+
+
+
+ {isCompleted ? t("jobDetail.finished") : isFailed ? t("jobDetail.failed") : t("jobDetail.cancelled")}
+
+
{new Date(job.finished_at).toLocaleString(locale)}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/MetadataReportCards.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/MetadataReportCards.tsx
new file mode 100644
index 0000000..d6d513a
--- /dev/null
+++ b/apps/backoffice/app/(app)/jobs/[id]/components/MetadataReportCards.tsx
@@ -0,0 +1,245 @@
+import Link from "next/link";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatBox } from "@/app/components/ui";
+import type { MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto } from "@/lib/api";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
+
+export function MetadataBatchReportCard({ report, t }: { report: MetadataBatchReportDto; t: TranslateFunction }) {
+ return (
+
+
+ {t("jobDetail.batchReport")}
+ {t("jobDetail.seriesAnalyzed", { count: String(report.total_series) })}
+
+
+
+
+
+
+
+
+ 0 ? "error" : "default"} />
+
+
+
+ );
+}
+
+export function MetadataBatchResultsCard({ results, libraryId, t }: {
+ results: MetadataBatchResultDto[];
+ libraryId: string | null;
+ t: TranslateFunction;
+}) {
+ if (results.length === 0) return null;
+
+ return (
+
+
+ {t("jobDetail.resultsBySeries")}
+ {t("jobDetail.seriesProcessed", { count: String(results.length) })}
+
+
+ {results.map((r) => (
+
+
+ {libraryId ? (
+
+ {r.series_name}
+
+ ) : (
+ {r.series_name}
+ )}
+
+ {r.status === "auto_matched" ? t("jobDetail.autoMatched") :
+ r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
+ r.status === "no_results" ? t("jobDetail.noResults") :
+ r.status === "too_many_results" ? t("jobDetail.tooManyResults") :
+ r.status === "low_confidence" ? t("jobDetail.lowConfidence") :
+ r.status === "error" ? t("common.error") :
+ r.status}
+
+
+
+ {r.provider_used && (
+ {r.provider_used}{r.fallback_used ? ` ${t("metadata.fallbackUsed")}` : ""}
+ )}
+ {r.candidates_count > 0 && (
+ {r.candidates_count} {t("jobDetail.candidates", { plural: r.candidates_count > 1 ? "s" : "" })}
+ )}
+ {r.best_confidence != null && (
+ {Math.round(r.best_confidence * 100)}% {t("jobDetail.confidence")}
+ )}
+
+ {r.best_candidate_json && (
+
+ {t("jobDetail.match", { title: (r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString() })}
+
+ )}
+ {r.error_message && (
+
{r.error_message}
+ )}
+
+ ))}
+
+
+ );
+}
+
+export function MetadataRefreshReportCard({ report, t }: { report: MetadataRefreshReportDto; t: TranslateFunction }) {
+ return (
+
+
+ {t("jobDetail.refreshReport")}
+ {t("jobDetail.refreshReportDesc", { count: String(report.total_links) })}
+
+
+
+
+
+
+ }
+ />
+
+ 0 ? "error" : "default"} />
+
+
+
+
+ );
+}
+
+export function MetadataRefreshChangesCard({ report, libraryId, t }: {
+ report: MetadataRefreshReportDto;
+ libraryId: string | null;
+ t: TranslateFunction;
+}) {
+ if (report.changes.length === 0) return null;
+
+ return (
+
+
+ {t("jobDetail.refreshChanges")}
+ {t("jobDetail.refreshChangesDesc", { count: String(report.changes.length) })}
+
+
+ {report.changes.map((r, idx) => (
+
+
+ {libraryId ? (
+
+ {r.series_name}
+
+ ) : (
+
{r.series_name}
+ )}
+
+ {r.provider}
+
+ {r.status === "updated" ? t("jobDetail.refreshed") :
+ r.status === "error" ? t("common.error") :
+ t("jobDetail.unchanged")}
+
+
+
+
+ {r.error && (
+
{r.error}
+ )}
+
+ {r.series_changes.length > 0 && (
+
+
{t("metadata.seriesLabel")}
+
+ {r.series_changes.map((c, ci) => (
+
+ {t(`field.${c.field}` as never) || c.field}
+
+ {c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old)) : "—"}
+
+ →
+
+ {c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new)) : "—"}
+
+
+ ))}
+
+
+ )}
+
+ {r.book_changes.length > 0 && (
+
+
+ {t("metadata.booksLabel")} ({r.book_changes.length})
+
+
+ {r.book_changes.map((b, bi) => (
+
+
+ {b.volume != null &&
T.{b.volume}}
+ {b.title}
+
+
+ {b.changes.map((c, ci) => (
+
+ {t(`field.${c.field}` as never) || c.field}
+
+ {c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old).substring(0, 60)) : "—"}
+
+ →
+
+ {c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new).substring(0, 60)) : "—"}
+
+
+ ))}
+
+
+ ))}
+
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/backoffice/app/(app)/jobs/[id]/components/ReadingStatusReportCards.tsx b/apps/backoffice/app/(app)/jobs/[id]/components/ReadingStatusReportCards.tsx
new file mode 100644
index 0000000..9dd861b
--- /dev/null
+++ b/apps/backoffice/app/(app)/jobs/[id]/components/ReadingStatusReportCards.tsx
@@ -0,0 +1,195 @@
+import Link from "next/link";
+import { Card, CardHeader, CardTitle, CardDescription, CardContent, StatBox } from "@/app/components/ui";
+import type { ReadingStatusMatchReportDto, ReadingStatusMatchResultDto, ReadingStatusPushReportDto, ReadingStatusPushResultDto } from "@/lib/api";
+import type { TranslateFunction } from "@/lib/i18n/dictionaries";
+
+export function ReadingStatusMatchReportCard({ report, t }: { report: ReadingStatusMatchReportDto; t: TranslateFunction }) {
+ return (
+
+
+ {t("jobDetail.readingStatusMatchReport")}
+ {t("jobDetail.seriesAnalyzed", { count: String(report.total_series) })}
+
+
+
+
+
+
+
+ 0 ? "error" : "default"} />
+
+
+
+ );
+}
+
+export function ReadingStatusMatchResultsCard({ results, libraryId, t }: {
+ results: ReadingStatusMatchResultDto[];
+ libraryId: string | null;
+ t: TranslateFunction;
+}) {
+ if (results.length === 0) return null;
+
+ return (
+
+
+ {t("jobDetail.resultsBySeries")}
+ {t("jobDetail.seriesProcessed", { count: String(results.length) })}
+
+
+ {results.map((r) => (
+
+
+ {libraryId ? (
+
+ {r.series_name}
+
+ ) : (
+ {r.series_name}
+ )}
+
+ {r.status === "linked" ? t("jobDetail.linked") :
+ r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
+ r.status === "no_results" ? t("jobDetail.noResults") :
+ r.status === "ambiguous" ? t("jobDetail.ambiguous") :
+ r.status === "error" ? t("common.error") :
+ r.status}
+
+
+ {r.status === "linked" && r.anilist_title && (
+
+
+ {r.anilist_url ? (
+
+ {r.anilist_title}
+
+ ) : (
+
{r.anilist_title}
+ )}
+ {r.anilist_id &&
#{r.anilist_id}}
+
+ )}
+ {r.error_message && (
+
{r.error_message}
+ )}
+
+ ))}
+
+
+ );
+}
+
+export function ReadingStatusPushReportCard({ report, t }: { report: ReadingStatusPushReportDto; t: TranslateFunction }) {
+ return (
+
+
+ {t("jobDetail.readingStatusPushReport")}
+ {t("jobDetail.seriesAnalyzed", { count: String(report.total_series) })}
+
+
+
+
+
+
+ 0 ? "error" : "default"} />
+
+
+
+ );
+}
+
+export function ReadingStatusPushResultsCard({ results, libraryId, t }: {
+ results: ReadingStatusPushResultDto[];
+ libraryId: string | null;
+ t: TranslateFunction;
+}) {
+ if (results.length === 0) return null;
+
+ return (
+
+
+ {t("jobDetail.resultsBySeries")}
+ {t("jobDetail.seriesProcessed", { count: String(results.length) })}
+
+
+ {results.map((r) => (
+
+
+ {libraryId ? (
+
+ {r.series_name}
+
+ ) : (
+ {r.series_name}
+ )}
+
+ {r.status === "pushed" ? t("jobDetail.pushed") :
+ r.status === "skipped" ? t("jobDetail.skipped") :
+ r.status === "no_books" ? t("jobDetail.noBooks") :
+ r.status === "error" ? t("common.error") :
+ r.status}
+
+
+ {r.status === "pushed" && r.anilist_title && (
+
+
+ {r.anilist_url ? (
+
+ {r.anilist_title}
+
+ ) : (
+
{r.anilist_title}
+ )}
+ {r.anilist_status &&
{r.anilist_status}}
+ {r.progress_volumes != null &&
vol. {r.progress_volumes}}
+
+ )}
+ {r.error_message && (
+
{r.error_message}
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/backoffice/app/(app)/jobs/[id]/page.tsx b/apps/backoffice/app/(app)/jobs/[id]/page.tsx
index 61b3343..9772b90 100644
--- a/apps/backoffice/app/(app)/jobs/[id]/page.tsx
+++ b/apps/backoffice/app/(app)/jobs/[id]/page.tsx
@@ -3,13 +3,16 @@ export const dynamic = "force-dynamic";
import { notFound } from "next/navigation";
import Link from "next/link";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, getReadingStatusMatchReport, getReadingStatusMatchResults, getReadingStatusPushReport, getReadingStatusPushResults, getDownloadDetectionReport, getDownloadDetectionResults, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto, ReadingStatusMatchReportDto, ReadingStatusMatchResultDto, ReadingStatusPushReportDto, ReadingStatusPushResultDto, DownloadDetectionReportDto, DownloadDetectionResultDto } from "@/lib/api";
-import {
- Card, CardHeader, CardTitle, CardDescription, CardContent,
- StatusBadge, JobTypeBadge, StatBox, ProgressBar
-} from "@/app/components/ui";
import { JobDetailLive } from "@/app/components/JobDetailLive";
-import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton";
import { getServerTranslations } from "@/lib/i18n/server";
+import { JobSummaryBanner } from "./components/JobSummaryBanner";
+import { JobOverviewCard } from "./components/JobOverviewCard";
+import { JobTimelineCard } from "./components/JobTimelineCard";
+import { JobProgressCard, IndexStatsCard, ThumbnailStatsCard } from "./components/JobProgressCard";
+import { MetadataBatchReportCard, MetadataBatchResultsCard, MetadataRefreshReportCard, MetadataRefreshChangesCard } from "./components/MetadataReportCards";
+import { ReadingStatusMatchReportCard, ReadingStatusMatchResultsCard, ReadingStatusPushReportCard, ReadingStatusPushResultsCard } from "./components/ReadingStatusReportCards";
+import { DownloadDetectionReportCard, DownloadDetectionResultsCard } from "./components/DownloadDetectionCards";
+import { JobErrorsCard } from "./components/JobErrorsCard";
interface JobDetailPageProps {
params: Promise<{ id: string }>;
@@ -47,7 +50,6 @@ interface JobError {
created_at: string;
}
-
async function getJobDetails(jobId: string): Promise {
try {
return await apiFetch(`/index/jobs/${jobId}`);
@@ -93,61 +95,17 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
const { t, locale } = await getServerTranslations();
const JOB_TYPE_INFO: Record = {
- rebuild: {
- label: t("jobType.rebuildLabel"),
- description: t("jobType.rebuildDesc"),
- isThumbnailOnly: false,
- },
- full_rebuild: {
- label: t("jobType.full_rebuildLabel"),
- description: t("jobType.full_rebuildDesc"),
- isThumbnailOnly: false,
- },
- rescan: {
- label: t("jobType.rescanLabel"),
- description: t("jobType.rescanDesc"),
- isThumbnailOnly: false,
- },
- thumbnail_rebuild: {
- label: t("jobType.thumbnail_rebuildLabel"),
- description: t("jobType.thumbnail_rebuildDesc"),
- isThumbnailOnly: true,
- },
- thumbnail_regenerate: {
- label: t("jobType.thumbnail_regenerateLabel"),
- description: t("jobType.thumbnail_regenerateDesc"),
- isThumbnailOnly: true,
- },
- cbr_to_cbz: {
- label: t("jobType.cbr_to_cbzLabel"),
- description: t("jobType.cbr_to_cbzDesc"),
- isThumbnailOnly: false,
- },
- metadata_batch: {
- label: t("jobType.metadata_batchLabel"),
- description: t("jobType.metadata_batchDesc"),
- isThumbnailOnly: false,
- },
- metadata_refresh: {
- label: t("jobType.metadata_refreshLabel"),
- description: t("jobType.metadata_refreshDesc"),
- isThumbnailOnly: false,
- },
- reading_status_match: {
- label: t("jobType.reading_status_matchLabel"),
- description: t("jobType.reading_status_matchDesc"),
- isThumbnailOnly: false,
- },
- reading_status_push: {
- label: t("jobType.reading_status_pushLabel"),
- description: t("jobType.reading_status_pushDesc"),
- isThumbnailOnly: false,
- },
- download_detection: {
- label: t("jobType.download_detectionLabel"),
- description: t("jobType.download_detectionDesc"),
- isThumbnailOnly: false,
- },
+ rebuild: { label: t("jobType.rebuildLabel"), description: t("jobType.rebuildDesc"), isThumbnailOnly: false },
+ full_rebuild: { label: t("jobType.full_rebuildLabel"), description: t("jobType.full_rebuildDesc"), isThumbnailOnly: false },
+ rescan: { label: t("jobType.rescanLabel"), description: t("jobType.rescanDesc"), isThumbnailOnly: false },
+ thumbnail_rebuild: { label: t("jobType.thumbnail_rebuildLabel"), description: t("jobType.thumbnail_rebuildDesc"), isThumbnailOnly: true },
+ thumbnail_regenerate: { label: t("jobType.thumbnail_regenerateLabel"), description: t("jobType.thumbnail_regenerateDesc"), isThumbnailOnly: true },
+ cbr_to_cbz: { label: t("jobType.cbr_to_cbzLabel"), description: t("jobType.cbr_to_cbzDesc"), isThumbnailOnly: false },
+ metadata_batch: { label: t("jobType.metadata_batchLabel"), description: t("jobType.metadata_batchDesc"), isThumbnailOnly: false },
+ metadata_refresh: { label: t("jobType.metadata_refreshLabel"), description: t("jobType.metadata_refreshDesc"), isThumbnailOnly: false },
+ reading_status_match: { label: t("jobType.reading_status_matchLabel"), description: t("jobType.reading_status_matchDesc"), isThumbnailOnly: false },
+ reading_status_push: { label: t("jobType.reading_status_pushLabel"), description: t("jobType.reading_status_pushDesc"), isThumbnailOnly: false },
+ download_detection: { label: t("jobType.download_detectionLabel"), description: t("jobType.download_detectionDesc"), isThumbnailOnly: false },
};
const isMetadataBatch = job.type === "metadata_batch";
@@ -156,7 +114,6 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
const isReadingStatusPush = job.type === "reading_status_push";
const isDownloadDetection = job.type === "download_detection";
- // Fetch batch report & results for metadata_batch jobs
let batchReport: MetadataBatchReportDto | null = null;
let batchResults: MetadataBatchResultDto[] = [];
if (isMetadataBatch) {
@@ -166,13 +123,11 @@ 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);
}
- // Fetch reading status match report & results
let readingStatusReport: ReadingStatusMatchReportDto | null = null;
let readingStatusResults: ReadingStatusMatchResultDto[] = [];
if (isReadingStatusMatch) {
@@ -182,7 +137,6 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
]);
}
- // Fetch reading status push report & results
let readingStatusPushReport: ReadingStatusPushReportDto | null = null;
let readingStatusPushResults: ReadingStatusPushResultDto[] = [];
if (isReadingStatusPush) {
@@ -192,7 +146,6 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
]);
}
- // Fetch download detection report & results
let downloadDetectionReport: DownloadDetectionReportDto | null = null;
let downloadDetectionResults: DownloadDetectionResultDto[] = [];
if (isDownloadDetection) {
@@ -202,11 +155,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
]);
}
- const typeInfo = JOB_TYPE_INFO[job.type] ?? {
- label: job.type,
- description: null,
- isThumbnailOnly: false,
- };
+ const typeInfo = JOB_TYPE_INFO[job.type] ?? { label: job.type, description: null, isThumbnailOnly: false };
+ const { isThumbnailOnly } = typeInfo;
const durationMs = job.started_at
? new Date(job.finished_at || new Date()).getTime() - new Date(job.started_at).getTime()
@@ -219,9 +169,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
const isExtractingPages = job.status === "extracting_pages";
const isThumbnailPhase = job.status === "generating_thumbnails";
const isPhase2 = isExtractingPages || isThumbnailPhase;
- const { isThumbnailOnly } = typeInfo;
- // Which label to use for the progress card
const progressTitle = isMetadataBatch
? t("jobDetail.metadataSearch")
: isMetadataRefresh
@@ -258,15 +206,6 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
? t("jobDetail.phase2bDesc")
: t("jobDetail.phase1Desc");
- // Speed metric: thumbnail count for thumbnail jobs, scanned files for index jobs
- const speedCount = isThumbnailOnly
- ? (job.processed_files ?? 0)
- : (job.stats_json?.scanned_files ?? 0);
-
- const showProgressCard =
- (isCompleted || isFailed || job.status === "running" || isPhase2) &&
- (job.total_files != null || !!job.current_file);
-
return (
<>
@@ -283,861 +222,61 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
{t("jobDetail.title")}
- {/* Summary banner — completed */}
- {isCompleted && job.started_at && (
-
-
-
- {t("jobDetail.completedIn", { duration: formatDuration(job.started_at, job.finished_at) })}
- {isMetadataBatch && batchReport && (
-
- — {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()}
-
- )}
- {isMetadataRefresh && refreshReport && (
-
- — {refreshReport.refreshed} {t("jobDetail.refreshed").toLowerCase()}, {refreshReport.unchanged} {t("jobDetail.unchanged").toLowerCase()}, {refreshReport.errors} {t("jobDetail.errors").toLowerCase()}
-
- )}
- {isReadingStatusMatch && readingStatusReport && (
-
- — {readingStatusReport.linked} {t("jobDetail.linked").toLowerCase()}, {readingStatusReport.no_results} {t("jobDetail.noResults").toLowerCase()}, {readingStatusReport.ambiguous} {t("jobDetail.ambiguous").toLowerCase()}, {readingStatusReport.errors} {t("jobDetail.errors").toLowerCase()}
-
- )}
- {isReadingStatusPush && readingStatusPushReport && (
-
- — {readingStatusPushReport.pushed} {t("jobDetail.pushed").toLowerCase()}, {readingStatusPushReport.no_books} {t("jobDetail.noBooks").toLowerCase()}, {readingStatusPushReport.errors} {t("jobDetail.errors").toLowerCase()}
-
- )}
- {isDownloadDetection && downloadDetectionReport && (
-
- — {downloadDetectionReport.found} {t("jobDetail.downloadFound").toLowerCase()}, {downloadDetectionReport.not_found} {t("jobDetail.downloadNotFound").toLowerCase()}, {downloadDetectionReport.errors} {t("jobDetail.errors").toLowerCase()}
-
- )}
- {!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !isDownloadDetection && job.stats_json && (
-
- — {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()}`}
- {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
- {job.stats_json.errors > 0 && `, ${job.stats_json.errors} ${t("jobDetail.errors").toLowerCase()}`}
- {job.total_files != null && job.total_files > 0 && `, ${job.total_files} ${t("jobType.thumbnail_rebuild").toLowerCase()}`}
-
- )}
- {!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !job.stats_json && isThumbnailOnly && job.total_files != null && (
-
- — {job.processed_files ?? job.total_files} {t("jobDetail.generated").toLowerCase()}
-
- )}
-
-
- )}
-
- {/* Summary banner — failed */}
- {isFailed && (
-
-
-
-
{t("jobDetail.jobFailed")}
- {job.started_at && (
-
{t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}
- )}
- {job.error_opt && (
-
{job.error_opt}
- )}
-
-
- )}
-
- {/* Summary banner — cancelled */}
- {isCancelled && (
-
-
-
- {t("jobDetail.cancelled")}
- {job.started_at && (
- {t("jobDetail.failedAfter", { duration: formatDuration(job.started_at, job.finished_at) })}
- )}
-
-
- )}
+
- {/* Overview Card */}
-
-
- {t("jobDetail.overview")}
- {typeInfo.description && (
- {typeInfo.description}
- )}
-
-
-
- ID
- {job.id}
-
-
-
{t("jobsList.type")}
-
-
- {typeInfo.label}
-
-
-
- {t("jobsList.status")}
-
-
-
- {t("jobDetail.library")}
- {job.library_id || t("jobDetail.allLibraries")}
-
- {job.book_id && (
-
- {t("jobDetail.book")}
-
- {job.book_id.slice(0, 8)}…
-
-
- )}
- {job.started_at && (
-
- {t("jobsList.duration")}
-
- {formatDuration(job.started_at, job.finished_at)}
-
-
- )}
-
-
+
+
- {/* Timeline Card */}
-
-
- {t("jobDetail.timeline")}
-
-
-
- {/* Vertical line */}
-
+
-
- {/* Created */}
-
-
-
-
{t("jobDetail.created")}
-
{new Date(job.created_at).toLocaleString(locale)}
-
-
-
- {/* Phase 1 start — for index jobs that have two phases */}
- {job.started_at && job.phase2_started_at && (
-
-
-
-
{t("jobDetail.phase1")}
-
{new Date(job.started_at).toLocaleString(locale)}
-
- {t("jobDetail.duration", { duration: formatDuration(job.started_at, job.phase2_started_at) })}
- {job.stats_json && (
-
- · {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()}`}
- {(job.stats_json.warnings ?? 0) > 0 && `, ${job.stats_json.warnings} ${t("jobDetail.warnings").toLowerCase()}`}
-
- )}
-
-
-
- )}
-
- {/* Phase 2a — Extracting pages (index jobs with phase2) */}
- {job.phase2_started_at && !isThumbnailOnly && (
-
-
-
-
{t("jobDetail.phase2a")}
-
{new Date(job.phase2_started_at).toLocaleString(locale)}
-
- {t("jobDetail.duration", { duration: formatDuration(job.phase2_started_at, job.generating_thumbnails_started_at ?? job.finished_at ?? null) })}
- {!job.generating_thumbnails_started_at && !job.finished_at && isExtractingPages && (
- · {t("jobDetail.inProgress")}
- )}
-
-
-
- )}
-
- {/* Phase 2b — Generating thumbnails */}
- {(job.generating_thumbnails_started_at || (job.phase2_started_at && isThumbnailOnly)) && (
-
-
-
-
- {isThumbnailOnly ? t("jobType.thumbnail_rebuild") : t("jobDetail.phase2b")}
-
-
- {(job.generating_thumbnails_started_at ? new Date(job.generating_thumbnails_started_at) : job.phase2_started_at ? new Date(job.phase2_started_at) : null)?.toLocaleString(locale)}
-
- {(job.generating_thumbnails_started_at || job.finished_at) && (
-
- {t("jobDetail.duration", { duration: formatDuration(
- job.generating_thumbnails_started_at ?? job.phase2_started_at!,
- job.finished_at ?? null
- ) })}
- {job.total_files != null && job.total_files > 0 && (
-
- · {job.processed_files ?? job.total_files} {t("jobType.thumbnail_rebuild").toLowerCase()}
-
- )}
-
- )}
- {!job.finished_at && isThumbnailPhase && (
-
{t("jobDetail.inProgress")}
- )}
-
-
- )}
-
- {/* Started — for jobs without phase2 (cbr_to_cbz, or no phase yet) */}
- {job.started_at && !job.phase2_started_at && (
-
-
-
-
{t("jobDetail.started")}
-
{new Date(job.started_at).toLocaleString(locale)}
-
-
- )}
-
- {/* Pending — not started yet */}
- {!job.started_at && (
-
-
-
- {t("jobDetail.pendingStart")}
-
-
- )}
-
- {/* Finished */}
- {job.finished_at && (
-
-
-
-
- {isCompleted ? t("jobDetail.finished") : isFailed ? t("jobDetail.failed") : t("jobDetail.cancelled")}
-
-
{new Date(job.finished_at).toLocaleString(locale)}
-
-
- )}
-
-
-
-
-
- {/* Progress Card */}
- {showProgressCard && (
-
-
- {progressTitle}
- {progressDescription && {progressDescription}}
-
-
- {job.total_files != null && job.total_files > 0 && (
- <>
-
-
-
-
-
-
- >
- )}
- {job.current_file && (
-
- {t("jobDetail.currentFile")}
- {job.current_file}
-
- )}
-
-
- )}
-
- {/* Index Statistics — index jobs only */}
+ {/* Index Statistics */}
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !isDownloadDetection && (
-
-
- {t("jobDetail.indexStats")}
- {job.started_at && (
-
- {formatDuration(job.started_at, job.finished_at)}
- {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} scan rate`}
-
- )}
-
-
-
-
-
-
- 0 ? "warning" : "default"} />
- 0 ? "error" : "default"} />
-
-
-
+
)}
- {/* Thumbnail statistics — thumbnail-only jobs, completed */}
+ {/* Thumbnail statistics */}
{isThumbnailOnly && isCompleted && job.total_files != null && (
-
-
- {t("jobDetail.thumbnailStats")}
- {job.started_at && (
-
- {formatDuration(job.started_at, job.finished_at)}
- {speedCount > 0 && ` · ${formatSpeed(speedCount, durationMs)} thumbnails/s`}
-
- )}
-
-
-
-
-
-
-
-
+
)}
- {/* Metadata batch report */}
- {isMetadataBatch && batchReport && (
-
-
- {t("jobDetail.batchReport")}
- {t("jobDetail.seriesAnalyzed", { count: String(batchReport.total_series) })}
-
-
-
-
-
-
-
-
- 0 ? "error" : "default"} />
-
-
-
- )}
+ {/* Metadata batch */}
+ {isMetadataBatch && batchReport &&
}
+ {isMetadataRefresh && refreshReport &&
}
+ {isMetadataRefresh && refreshReport &&
}
- {/* Metadata refresh report */}
- {isMetadataRefresh && refreshReport && (
-
-
- {t("jobDetail.refreshReport")}
- {t("jobDetail.refreshReportDesc", { count: String(refreshReport.total_links) })}
-
-
-
-
-
-
- }
- />
-
- 0 ? "error" : "default"} />
-
-
-
-
- )}
+ {/* Reading status */}
+ {isReadingStatusMatch && readingStatusReport &&
}
+ {isReadingStatusMatch &&
}
+ {isReadingStatusPush && readingStatusPushReport &&
}
+ {isReadingStatusPush &&
}
- {/* Metadata refresh changes detail */}
- {isMetadataRefresh && refreshReport && refreshReport.changes.length > 0 && (
-
-
- {t("jobDetail.refreshChanges")}
- {t("jobDetail.refreshChangesDesc", { count: String(refreshReport.changes.length) })}
-
-
- {refreshReport.changes.map((r, idx) => (
-
-
- {job.library_id ? (
-
- {r.series_name}
-
- ) : (
-
{r.series_name}
- )}
-
- {r.provider}
-
- {r.status === "updated" ? t("jobDetail.refreshed") :
- r.status === "error" ? t("common.error") :
- t("jobDetail.unchanged")}
-
-
-
-
- {r.error && (
-
{r.error}
- )}
-
- {/* Series field changes */}
- {r.series_changes.length > 0 && (
-
-
{t("metadata.seriesLabel")}
-
- {r.series_changes.map((c, ci) => (
-
- {t(`field.${c.field}` as never) || c.field}
-
- {c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old)) : "—"}
-
- →
-
- {c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new)) : "—"}
-
-
- ))}
-
-
- )}
-
- {/* Book field changes */}
- {r.book_changes.length > 0 && (
-
-
- {t("metadata.booksLabel")} ({r.book_changes.length})
-
-
- {r.book_changes.map((b, bi) => (
-
-
- {b.volume != null &&
T.{b.volume}}
- {b.title}
-
-
- {b.changes.map((c, ci) => (
-
- {t(`field.${c.field}` as never) || c.field}
-
- {c.old != null ? (Array.isArray(c.old) ? (c.old as string[]).join(", ") : String(c.old).substring(0, 60)) : "—"}
-
- →
-
- {c.new != null ? (Array.isArray(c.new) ? (c.new as string[]).join(", ") : String(c.new).substring(0, 60)) : "—"}
-
-
- ))}
-
-
- ))}
-
-
- )}
-
- ))}
-
-
- )}
-
- {/* Reading status match — summary report */}
- {isReadingStatusMatch && readingStatusReport && (
-
-
- {t("jobDetail.readingStatusMatchReport")}
- {t("jobDetail.seriesAnalyzed", { count: String(readingStatusReport.total_series) })}
-
-
-
-
-
-
-
- 0 ? "error" : "default"} />
-
-
-
- )}
-
- {/* Reading status match — per-series detail */}
- {isReadingStatusMatch && readingStatusResults.length > 0 && (
-
-
- {t("jobDetail.resultsBySeries")}
- {t("jobDetail.seriesProcessed", { count: String(readingStatusResults.length) })}
-
-
- {readingStatusResults.map((r) => (
-
-
- {job.library_id ? (
-
- {r.series_name}
-
- ) : (
- {r.series_name}
- )}
-
- {r.status === "linked" ? t("jobDetail.linked") :
- r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
- r.status === "no_results" ? t("jobDetail.noResults") :
- r.status === "ambiguous" ? t("jobDetail.ambiguous") :
- r.status === "error" ? t("common.error") :
- r.status}
-
-
- {r.status === "linked" && r.anilist_title && (
-
-
- {r.anilist_url ? (
-
- {r.anilist_title}
-
- ) : (
-
{r.anilist_title}
- )}
- {r.anilist_id &&
#{r.anilist_id}}
-
- )}
- {r.error_message && (
-
{r.error_message}
- )}
-
- ))}
-
-
- )}
-
- {/* Reading status push — summary report */}
- {isReadingStatusPush && readingStatusPushReport && (
-
-
- {t("jobDetail.readingStatusPushReport")}
- {t("jobDetail.seriesAnalyzed", { count: String(readingStatusPushReport.total_series) })}
-
-
-
-
-
-
- 0 ? "error" : "default"} />
-
-
-
- )}
-
- {/* Reading status push — per-series detail */}
- {isReadingStatusPush && readingStatusPushResults.length > 0 && (
-
-
- {t("jobDetail.resultsBySeries")}
- {t("jobDetail.seriesProcessed", { count: String(readingStatusPushResults.length) })}
-
-
- {readingStatusPushResults.map((r) => (
-
-
- {job.library_id ? (
-
- {r.series_name}
-
- ) : (
- {r.series_name}
- )}
-
- {r.status === "pushed" ? t("jobDetail.pushed") :
- r.status === "skipped" ? t("jobDetail.skipped") :
- r.status === "no_books" ? t("jobDetail.noBooks") :
- r.status === "error" ? t("common.error") :
- r.status}
-
-
- {r.status === "pushed" && r.anilist_title && (
-
-
- {r.anilist_url ? (
-
- {r.anilist_title}
-
- ) : (
-
{r.anilist_title}
- )}
- {r.anilist_status &&
{r.anilist_status}}
- {r.progress_volumes != null &&
vol. {r.progress_volumes}}
-
- )}
- {r.error_message && (
-
{r.error_message}
- )}
-
- ))}
-
-
- )}
-
- {/* Download detection — summary report */}
- {isDownloadDetection && downloadDetectionReport && (
-
-
- {t("jobDetail.downloadDetectionReport")}
- {t("jobDetail.seriesAnalyzed", { count: String(downloadDetectionReport.total_series) })}
-
-
-
-
-
-
-
- 0 ? "error" : "default"} />
-
-
-
- )}
-
- {/* Download detection — available releases per series */}
- {isDownloadDetection && downloadDetectionResults.length > 0 && (
-
-
-
- {t("jobDetail.downloadAvailableReleases")}
- {t("jobDetail.downloadAvailableReleasesDesc", { count: String(downloadDetectionResults.length) })}
-
-
- {downloadDetectionResults.map((r) => (
-
-
- {job.library_id ? (
-
- {r.series_name}
-
- ) : (
- {r.series_name}
- )}
-
- {t("jobDetail.downloadMissingCount", { count: String(r.missing_count) })}
-
-
- {r.available_releases && r.available_releases.length > 0 && (
-
- {r.available_releases.map((release, idx) => (
-
-
-
{release.title}
-
- {release.indexer && (
-
{release.indexer}
- )}
- {release.seeders != null && (
-
{release.seeders} {t("prowlarr.columnSeeders").toLowerCase()}
- )}
-
- {(release.size / 1024 / 1024).toFixed(0)} MB
-
-
- {release.matched_missing_volumes.map((vol) => (
-
- T.{vol}
-
- ))}
-
-
-
- {release.download_url && (
-
- )}
-
- ))}
-
- )}
-
- ))}
-
-
-
- )}
+ {/* Download detection */}
+ {isDownloadDetection && downloadDetectionReport &&
}
+ {isDownloadDetection &&
}
{/* Metadata batch results */}
- {isMetadataBatch && batchResults.length > 0 && (
-
-
- {t("jobDetail.resultsBySeries")}
- {t("jobDetail.seriesProcessed", { count: String(batchResults.length) })}
-
-
- {batchResults.map((r) => (
-
-
- {job.library_id ? (
-
- {r.series_name}
-
- ) : (
- {r.series_name}
- )}
-
- {r.status === "auto_matched" ? t("jobDetail.autoMatched") :
- r.status === "already_linked" ? t("jobDetail.alreadyLinked") :
- r.status === "no_results" ? t("jobDetail.noResults") :
- r.status === "too_many_results" ? t("jobDetail.tooManyResults") :
- r.status === "low_confidence" ? t("jobDetail.lowConfidence") :
- r.status === "error" ? t("common.error") :
- r.status}
-
-
-
- {r.provider_used && (
- {r.provider_used}{r.fallback_used ? ` ${t("metadata.fallbackUsed")}` : ""}
- )}
- {r.candidates_count > 0 && (
- {r.candidates_count} {t("jobDetail.candidates", { plural: r.candidates_count > 1 ? "s" : "" })}
- )}
- {r.best_confidence != null && (
- {Math.round(r.best_confidence * 100)}% {t("jobDetail.confidence")}
- )}
-
- {r.best_candidate_json && (
-
- {t("jobDetail.match", { title: (r.best_candidate_json as { title?: string }).title || r.best_candidate_json.toString() })}
-
- )}
- {r.error_message && (
-
{r.error_message}
- )}
-
- ))}
-
-
- )}
+ {isMetadataBatch &&
}
{/* File errors */}
- {errors.length > 0 && (
-
-
- {t("jobDetail.fileErrors", { count: String(errors.length) })}
- {t("jobDetail.fileErrorsDesc")}
-
-
- {errors.map((error) => (
-
-
{error.file_path}
-
{error.error_message}
-
{new Date(error.created_at).toLocaleString(locale)}
-
- ))}
-
-
- )}
+
>
);