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)} -
- ))} -
-
- )} +
);