feat: add download detection job with Prowlarr integration

For each series with missing volumes and an approved metadata link,
calls Prowlarr to find available matching releases and stores them in
a report (no auto-download). Includes per-series detail page, Telegram
notifications with per-event toggles, and stats display in the jobs table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:47:29 +01:00
parent e5e4993e7b
commit d2c9f28227
15 changed files with 1033 additions and 13 deletions

View File

@@ -2,7 +2,7 @@ export const dynamic = "force-dynamic";
import { notFound } from "next/navigation";
import Link from "next/link";
import { apiFetch, getMetadataBatchReport, getMetadataBatchResults, getMetadataRefreshReport, getReadingStatusMatchReport, getReadingStatusMatchResults, getReadingStatusPushReport, getReadingStatusPushResults, MetadataBatchReportDto, MetadataBatchResultDto, MetadataRefreshReportDto, ReadingStatusMatchReportDto, ReadingStatusMatchResultDto, ReadingStatusPushReportDto, ReadingStatusPushResultDto } from "@/lib/api";
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
@@ -142,12 +142,18 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
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";
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";
// Fetch batch report & results for metadata_batch jobs
let batchReport: MetadataBatchReportDto | null = null;
@@ -185,6 +191,16 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
]);
}
// Fetch download detection report & results
let downloadDetectionReport: DownloadDetectionReportDto | null = null;
let downloadDetectionResults: DownloadDetectionResultDto[] = [];
if (isDownloadDetection) {
[downloadDetectionReport, downloadDetectionResults] = await Promise.all([
getDownloadDetectionReport(id).catch(() => null),
getDownloadDetectionResults(id, "found").catch(() => []),
]);
}
const typeInfo = JOB_TYPE_INFO[job.type] ?? {
label: job.type,
description: null,
@@ -213,6 +229,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
? t("jobDetail.readingStatusMatch")
: isReadingStatusPush
? t("jobDetail.readingStatusPush")
: isDownloadDetection
? t("jobDetail.downloadDetection")
: isThumbnailOnly
? t("jobType.thumbnail_rebuild")
: isExtractingPages
@@ -229,6 +247,8 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
? t("jobDetail.readingStatusMatchDesc")
: isReadingStatusPush
? t("jobDetail.readingStatusPushDesc")
: isDownloadDetection
? t("jobDetail.downloadDetectionDesc")
: isThumbnailOnly
? undefined
: isExtractingPages
@@ -290,7 +310,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
{readingStatusPushReport.pushed} {t("jobDetail.pushed").toLowerCase()}, {readingStatusPushReport.no_books} {t("jobDetail.noBooks").toLowerCase()}, {readingStatusPushReport.errors} {t("jobDetail.errors").toLowerCase()}
</span>
)}
{!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && job.stats_json && (
{isDownloadDetection && downloadDetectionReport && (
<span className="ml-2 text-success/80">
{downloadDetectionReport.found} {t("jobDetail.downloadFound").toLowerCase()}, {downloadDetectionReport.not_found} {t("jobDetail.downloadNotFound").toLowerCase()}, {downloadDetectionReport.errors} {t("jobDetail.errors").toLowerCase()}
</span>
)}
{!isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !isDownloadDetection && job.stats_json && (
<span className="ml-2 text-success/80">
{job.stats_json.scanned_files} {t("jobDetail.scanned").toLowerCase()}, {job.stats_json.indexed_files} {t("jobDetail.indexed").toLowerCase()}
{job.stats_json.removed_files > 0 && `, ${job.stats_json.removed_files} ${t("jobDetail.removed").toLowerCase()}`}
@@ -564,7 +589,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
)}
{/* Index Statistics — index jobs only */}
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && (
{job.stats_json && !isThumbnailOnly && !isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !isDownloadDetection && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.indexStats")}</CardTitle>
@@ -938,6 +963,85 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
</Card>
)}
{/* Download detection — summary report */}
{isDownloadDetection && downloadDetectionReport && (
<Card>
<CardHeader>
<CardTitle>{t("jobDetail.downloadDetectionReport")}</CardTitle>
<CardDescription>{t("jobDetail.seriesAnalyzed", { count: String(downloadDetectionReport.total_series) })}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<StatBox value={downloadDetectionReport.found} label={t("jobDetail.downloadFound")} variant="success" />
<StatBox value={downloadDetectionReport.not_found} label={t("jobDetail.downloadNotFound")} />
<StatBox value={downloadDetectionReport.no_missing} label={t("jobDetail.downloadNoMissing")} variant="primary" />
<StatBox value={downloadDetectionReport.no_metadata} label={t("jobDetail.downloadNoMetadata")} />
<StatBox value={downloadDetectionReport.errors} label={t("jobDetail.errors")} variant={downloadDetectionReport.errors > 0 ? "error" : "default"} />
</div>
</CardContent>
</Card>
)}
{/* Download detection — available releases per series */}
{isDownloadDetection && downloadDetectionResults.length > 0 && (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>{t("jobDetail.downloadAvailableReleases")}</CardTitle>
<CardDescription>{t("jobDetail.downloadAvailableReleasesDesc", { count: String(downloadDetectionResults.length) })}</CardDescription>
</CardHeader>
<CardContent className="space-y-4 max-h-[700px] overflow-y-auto">
{downloadDetectionResults.map((r) => (
<div key={r.id} className="rounded-lg border border-success/20 bg-success/5 p-3">
<div className="flex items-center justify-between gap-2 mb-2">
{job.library_id ? (
<Link
href={`/libraries/${job.library_id}/series/${encodeURIComponent(r.series_name)}`}
className="font-semibold text-sm text-primary hover:underline truncate"
>
{r.series_name}
</Link>
) : (
<span className="font-semibold text-sm text-foreground truncate">{r.series_name}</span>
)}
<span className="text-[10px] px-1.5 py-0.5 rounded-full font-medium whitespace-nowrap bg-warning/20 text-warning shrink-0">
{t("jobDetail.downloadMissingCount", { count: String(r.missing_count) })}
</span>
</div>
{r.available_releases && r.available_releases.length > 0 && (
<div className="space-y-1.5">
{r.available_releases.map((release, idx) => (
<div key={idx} className="flex items-start gap-2 p-2 rounded bg-background/60 border border-border/40">
<div className="flex-1 min-w-0">
<p className="text-xs font-mono text-foreground truncate" title={release.title}>{release.title}</p>
<div className="flex items-center gap-3 mt-1 flex-wrap">
{release.indexer && (
<span className="text-[10px] text-muted-foreground">{release.indexer}</span>
)}
{release.seeders != null && (
<span className="text-[10px] text-success font-medium">{release.seeders} {t("prowlarr.columnSeeders").toLowerCase()}</span>
)}
<span className="text-[10px] text-muted-foreground">
{(release.size / 1024 / 1024).toFixed(0)} MB
</span>
<div className="flex items-center gap-1">
{release.matched_missing_volumes.map((vol) => (
<span key={vol} className="text-[10px] px-1.5 py-0.5 rounded-full bg-success/20 text-success font-medium">
T.{vol}
</span>
))}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
))}
</CardContent>
</Card>
)}
{/* Metadata batch results */}
{isMetadataBatch && batchResults.length > 0 && (
<Card className="lg:col-span-2">

View File

@@ -1,6 +1,6 @@
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, startReadingStatusMatch, startReadingStatusPush, IndexJobDto, LibraryDto } from "@/lib/api";
import { listJobs, fetchLibraries, rebuildIndex, rebuildThumbnails, regenerateThumbnails, startMetadataBatch, startMetadataRefresh, startReadingStatusMatch, startReadingStatusPush, startDownloadDetection, apiFetch, IndexJobDto, LibraryDto } from "@/lib/api";
import { JobsList } from "@/app/components/JobsList";
import { Card, CardHeader, CardTitle, CardDescription, CardContent, FormField, FormSelect } from "@/app/components/ui";
import { getServerTranslations } from "@/lib/i18n/server";
@@ -10,10 +10,12 @@ export const dynamic = "force-dynamic";
export default async function JobsPage({ searchParams }: { searchParams: Promise<{ highlight?: string }> }) {
const { highlight } = await searchParams;
const { t } = await getServerTranslations();
const [jobs, libraries] = await Promise.all([
const [jobs, libraries, prowlarrSettings] = await Promise.all([
listJobs().catch(() => [] as IndexJobDto[]),
fetchLibraries().catch(() => [] as LibraryDto[])
fetchLibraries().catch(() => [] as LibraryDto[]),
apiFetch<{ url?: string }>("/settings/prowlarr").catch(() => null),
]);
const prowlarrConfigured = !!prowlarrSettings?.url;
const libraryMap = new Map(libraries.map(l => [l.id, l.name]));
const readingStatusLibraries = libraries.filter(l => l.reading_status_provider);
@@ -179,6 +181,35 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
}
}
async function triggerDownloadDetection(formData: FormData) {
"use server";
const libraryId = formData.get("library_id") as string;
if (libraryId) {
let result;
try {
result = await startDownloadDetection(libraryId);
} catch {
return;
}
revalidatePath("/jobs");
redirect(`/jobs?highlight=${result.id}`);
} else {
// All libraries
const allLibraries = await fetchLibraries().catch(() => [] as LibraryDto[]);
let lastId: string | undefined;
for (const lib of allLibraries) {
try {
const result = await startDownloadDetection(lib.id);
if (result.status !== "already_running") lastId = result.id;
} catch {
// Skip libraries with errors (e.g. Prowlarr not configured)
}
}
revalidatePath("/jobs");
redirect(lastId ? `/jobs?highlight=${lastId}` : "/jobs");
}
}
return (
<>
<div className="mb-6">
@@ -349,6 +380,28 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise
</div>
)}
{/* Download group — only shown if Prowlarr is configured */}
{prowlarrConfigured && <div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-foreground">
<svg className="w-4 h-4 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{t("jobs.groupProwlarr")}
</div>
<div className="space-y-2">
<button type="submit" formAction={triggerDownloadDetection}
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
<span className="font-medium text-sm text-foreground">{t("jobs.downloadDetection")}</span>
</div>
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.downloadDetectionShort")}</p>
</button>
</div>
</div>}
</div>
</form>
</CardContent>

View File

@@ -17,6 +17,12 @@ export const DEFAULT_EVENTS = {
metadata_batch_failed: true,
metadata_refresh_completed: true,
metadata_refresh_failed: true,
reading_status_match_completed: true,
reading_status_match_failed: true,
reading_status_push_completed: true,
reading_status_push_failed: true,
download_detection_completed: true,
download_detection_failed: true,
};
export function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
@@ -191,6 +197,24 @@ export function TelegramCard({ handleUpdateSetting }: { handleUpdateSetting: (ke
{ key: "metadata_refresh_failed" as const, label: t("settings.eventRefreshFailed") },
],
},
{
category: t("settings.eventCategoryReadingStatus"),
icon: "books" as const,
items: [
{ key: "reading_status_match_completed" as const, label: t("settings.eventMatchCompleted") },
{ key: "reading_status_match_failed" as const, label: t("settings.eventMatchFailed") },
{ key: "reading_status_push_completed" as const, label: t("settings.eventPushCompleted") },
{ key: "reading_status_push_failed" as const, label: t("settings.eventPushFailed") },
],
},
{
category: t("settings.eventCategoryDownloadDetection"),
icon: "download" as const,
items: [
{ key: "download_detection_completed" as const, label: t("settings.eventCompleted") },
{ key: "download_detection_failed" as const, label: t("settings.eventFailed") },
],
},
]).map(({ category, icon, items }) => (
<div key={category}>
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2 flex items-center gap-1.5">