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:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -24,6 +24,7 @@ interface JobRowProps {
|
||||
refreshed?: number;
|
||||
linked?: number;
|
||||
pushed?: number;
|
||||
found?: number;
|
||||
} | null;
|
||||
progress_percent: number | null;
|
||||
processed_files: number | null;
|
||||
@@ -69,6 +70,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, form
|
||||
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";
|
||||
|
||||
// Thumbnails progress (Phase 2: extracting_pages + generating_thumbnails)
|
||||
const thumbInProgress = hasThumbnailPhase && (job.status === "running" || isPhase2);
|
||||
@@ -210,6 +212,23 @@ export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, form
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Download detection: total series + found count */}
|
||||
{isDownloadDetection && job.total_files != null && job.total_files > 0 && (
|
||||
<Tooltip label={t("jobRow.seriesTotal", { count: job.total_files })}>
|
||||
<span className="inline-flex items-center gap-1 text-info">
|
||||
<Icon name="series" size="sm" />
|
||||
{job.total_files}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isDownloadDetection && job.stats_json?.found != null && job.stats_json.found > 0 && (
|
||||
<Tooltip label={t("jobRow.downloadFound", { count: job.stats_json.found })}>
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<Icon name="download" size="sm" />
|
||||
{job.stats_json.found}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Errors */}
|
||||
{errors > 0 && (
|
||||
<Tooltip label={t("jobRow.errors", { count: errors })}>
|
||||
@@ -229,7 +248,7 @@ export function JobRow({ job, libraryName, highlighted, onCancel, onReplay, form
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Nothing to show */}
|
||||
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && (
|
||||
{indexed === 0 && removed === 0 && errors === 0 && scanned === 0 && !hasThumbnailPhase && !isMetadataBatch && !isMetadataRefresh && !isReadingStatusMatch && !isReadingStatusPush && !isDownloadDetection && (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,8 @@ type IconName =
|
||||
| "authors"
|
||||
| "bell"
|
||||
| "link"
|
||||
| "eye";
|
||||
| "eye"
|
||||
| "download";
|
||||
|
||||
type IconSize = "sm" | "md" | "lg" | "xl";
|
||||
|
||||
@@ -94,6 +95,7 @@ const icons: Record<IconName, string> = {
|
||||
bell: "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9",
|
||||
link: "M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1",
|
||||
eye: "M15 12a3 3 0 11-6 0 3 3 0 016 0zm-3-9C7.477 3 3.268 6.11 1.5 12c1.768 5.89 5.977 9 10.5 9s8.732-3.11 10.5-9C20.732 6.11 16.523 3 12 3z",
|
||||
download: "M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4",
|
||||
};
|
||||
|
||||
const colorClasses: Partial<Record<IconName, string>> = {
|
||||
|
||||
@@ -1141,6 +1141,53 @@ export async function getReadingStatusPushResults(jobId: string) {
|
||||
return apiFetch<ReadingStatusPushResultDto[]>(`/reading-status/push/${jobId}/results`);
|
||||
}
|
||||
|
||||
export async function startDownloadDetection(libraryId: string) {
|
||||
return apiFetch<{ id: string; status: string }>("/download-detection/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ library_id: libraryId }),
|
||||
});
|
||||
}
|
||||
|
||||
export type AvailableReleaseDto = {
|
||||
title: string;
|
||||
size: number;
|
||||
download_url: string | null;
|
||||
indexer: string | null;
|
||||
seeders: number | null;
|
||||
matched_missing_volumes: number[];
|
||||
};
|
||||
|
||||
export type DownloadDetectionReportDto = {
|
||||
job_id: string;
|
||||
status: string;
|
||||
total_series: number;
|
||||
found: number;
|
||||
not_found: number;
|
||||
no_missing: number;
|
||||
no_metadata: number;
|
||||
errors: number;
|
||||
};
|
||||
|
||||
export type DownloadDetectionResultDto = {
|
||||
id: string;
|
||||
series_name: string;
|
||||
status: "found" | "not_found" | "no_missing" | "no_metadata" | "error";
|
||||
missing_count: number;
|
||||
available_releases: AvailableReleaseDto[] | null;
|
||||
error_message: string | null;
|
||||
};
|
||||
|
||||
export async function getDownloadDetectionReport(jobId: string) {
|
||||
return apiFetch<DownloadDetectionReportDto>(`/download-detection/${jobId}/report`);
|
||||
}
|
||||
|
||||
export async function getDownloadDetectionResults(jobId: string, status?: string) {
|
||||
const url = status
|
||||
? `/download-detection/${jobId}/results?status=${encodeURIComponent(status)}`
|
||||
: `/download-detection/${jobId}/results`;
|
||||
return apiFetch<DownloadDetectionResultDto[]>(url);
|
||||
}
|
||||
|
||||
export type RefreshFieldDiff = {
|
||||
field: string;
|
||||
old?: unknown;
|
||||
|
||||
@@ -266,6 +266,9 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobs.matchReadingStatusShort": "Auto-link unmatched series to the reading status provider",
|
||||
"jobs.pushReadingStatus": "Push reading statuses",
|
||||
"jobs.pushReadingStatusShort": "Push changed reading statuses to AniList (differential push)",
|
||||
"jobs.groupProwlarr": "Download",
|
||||
"jobs.downloadDetection": "Download detection",
|
||||
"jobs.downloadDetectionShort": "Search Prowlarr for available releases matching missing volumes",
|
||||
|
||||
// Jobs list
|
||||
"jobsList.id": "ID",
|
||||
@@ -290,6 +293,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobRow.seriesTotal": "{{count}} series total",
|
||||
"jobRow.seriesLinked": "{{count}} series linked",
|
||||
"jobRow.seriesPushed": "{{count}} series pushed",
|
||||
"jobRow.downloadFound": "{{count}} releases found",
|
||||
"jobRow.errors": "{{count}} errors",
|
||||
"jobRow.view": "View",
|
||||
"jobRow.replay": "Replay",
|
||||
@@ -381,6 +385,16 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobDetail.pushed": "Pushed",
|
||||
"jobDetail.skipped": "Skipped",
|
||||
"jobDetail.noBooks": "No books",
|
||||
"jobDetail.downloadDetection": "Download detection",
|
||||
"jobDetail.downloadDetectionDesc": "Scanning series with missing volumes via Prowlarr",
|
||||
"jobDetail.downloadDetectionReport": "Detection report",
|
||||
"jobDetail.downloadFound": "Available",
|
||||
"jobDetail.downloadNotFound": "Not found",
|
||||
"jobDetail.downloadNoMissing": "Complete",
|
||||
"jobDetail.downloadNoMetadata": "No metadata",
|
||||
"jobDetail.downloadAvailableReleases": "Available releases",
|
||||
"jobDetail.downloadAvailableReleasesDesc": "{{count}} series with at least one release found",
|
||||
"jobDetail.downloadMissingCount": "{{count}} missing",
|
||||
|
||||
// Job types
|
||||
"jobType.rebuild": "Indexing",
|
||||
@@ -413,6 +427,9 @@ const en: Record<TranslationKey, string> = {
|
||||
"jobType.reading_status_push": "Reading status push",
|
||||
"jobType.reading_status_pushLabel": "Reading status push",
|
||||
"jobType.reading_status_pushDesc": "Differentially pushes changed reading statuses (or new series) to AniList.",
|
||||
"jobType.download_detection": "Download detection",
|
||||
"jobType.download_detectionLabel": "Available downloads detection",
|
||||
"jobType.download_detectionDesc": "Scans series with missing volumes and queries Prowlarr to find available releases. Downloads nothing — produces a report of opportunities only.",
|
||||
|
||||
// Status badges
|
||||
"statusBadge.extracting_pages": "Extracting pages",
|
||||
@@ -647,6 +664,12 @@ const en: Record<TranslationKey, string> = {
|
||||
"settings.eventBatchFailed": "Batch failed",
|
||||
"settings.eventRefreshCompleted": "Refresh completed",
|
||||
"settings.eventRefreshFailed": "Refresh failed",
|
||||
"settings.eventCategoryReadingStatus": "Reading status",
|
||||
"settings.eventMatchCompleted": "Sync completed",
|
||||
"settings.eventMatchFailed": "Sync failed",
|
||||
"settings.eventPushCompleted": "Push completed",
|
||||
"settings.eventPushFailed": "Push failed",
|
||||
"settings.eventCategoryDownloadDetection": "Download detection",
|
||||
"settings.telegramHelp": "How to get the required information?",
|
||||
"settings.telegramHelpBot": "Open Telegram, search for <b>@BotFather</b>, send <code>/newbot</code> and follow the instructions. Copy the token it gives you.",
|
||||
"settings.telegramHelpChat": "Send a message to your bot, then open <code>https://api.telegram.org/bot<TOKEN>/getUpdates</code> in your browser. The <b>chat id</b> is in <code>message.chat.id</code>.",
|
||||
|
||||
@@ -264,6 +264,9 @@ const fr = {
|
||||
"jobs.matchReadingStatusShort": "Lier automatiquement les séries non associées au provider",
|
||||
"jobs.pushReadingStatus": "Push des états de lecture",
|
||||
"jobs.pushReadingStatusShort": "Envoyer les états de lecture modifiés vers AniList (push différentiel)",
|
||||
"jobs.groupProwlarr": "Téléchargement",
|
||||
"jobs.downloadDetection": "Détection de téléchargements",
|
||||
"jobs.downloadDetectionShort": "Cherche sur Prowlarr les releases disponibles pour les volumes manquants",
|
||||
|
||||
// Jobs list
|
||||
"jobsList.id": "ID",
|
||||
@@ -288,6 +291,7 @@ const fr = {
|
||||
"jobRow.seriesTotal": "{{count}} séries au total",
|
||||
"jobRow.seriesLinked": "{{count}} séries liées",
|
||||
"jobRow.seriesPushed": "{{count}} séries synchronisées",
|
||||
"jobRow.downloadFound": "{{count}} releases trouvées",
|
||||
"jobRow.errors": "{{count}} erreurs",
|
||||
"jobRow.view": "Voir",
|
||||
"jobRow.replay": "Rejouer",
|
||||
@@ -379,6 +383,16 @@ const fr = {
|
||||
"jobDetail.pushed": "Envoyés",
|
||||
"jobDetail.skipped": "Ignorés",
|
||||
"jobDetail.noBooks": "Sans livres",
|
||||
"jobDetail.downloadDetection": "Détection de téléchargements",
|
||||
"jobDetail.downloadDetectionDesc": "Analyse des séries avec volumes manquants via Prowlarr",
|
||||
"jobDetail.downloadDetectionReport": "Rapport de détection",
|
||||
"jobDetail.downloadFound": "Disponibles",
|
||||
"jobDetail.downloadNotFound": "Non trouvés",
|
||||
"jobDetail.downloadNoMissing": "Complets",
|
||||
"jobDetail.downloadNoMetadata": "Sans métadonnées",
|
||||
"jobDetail.downloadAvailableReleases": "Releases disponibles",
|
||||
"jobDetail.downloadAvailableReleasesDesc": "{{count}} série(s) avec au moins une release trouvée",
|
||||
"jobDetail.downloadMissingCount": "{{count}} manquant(s)",
|
||||
|
||||
// Job types
|
||||
"jobType.rebuild": "Indexation",
|
||||
@@ -411,6 +425,9 @@ const fr = {
|
||||
"jobType.reading_status_push": "Push statut lecture",
|
||||
"jobType.reading_status_pushLabel": "Push des états de lecture",
|
||||
"jobType.reading_status_pushDesc": "Envoie les états de lecture modifiés (ou nouvelles séries) vers AniList de façon différentielle.",
|
||||
"jobType.download_detection": "Détection téléchargements",
|
||||
"jobType.download_detectionLabel": "Détection de téléchargements disponibles",
|
||||
"jobType.download_detectionDesc": "Analyse les séries avec des volumes manquants et interroge Prowlarr pour trouver les releases disponibles. Ne télécharge rien — produit uniquement un rapport des opportunités.",
|
||||
|
||||
// Status badges
|
||||
"statusBadge.extracting_pages": "Extraction des pages",
|
||||
@@ -645,6 +662,12 @@ const fr = {
|
||||
"settings.eventBatchFailed": "Batch échoué",
|
||||
"settings.eventRefreshCompleted": "Rafraîchissement terminé",
|
||||
"settings.eventRefreshFailed": "Rafraîchissement échoué",
|
||||
"settings.eventCategoryReadingStatus": "État de lecture",
|
||||
"settings.eventMatchCompleted": "Synchro. terminée",
|
||||
"settings.eventMatchFailed": "Synchro. échouée",
|
||||
"settings.eventPushCompleted": "Push terminé",
|
||||
"settings.eventPushFailed": "Push échoué",
|
||||
"settings.eventCategoryDownloadDetection": "Détection téléchargements",
|
||||
"settings.telegramHelp": "Comment obtenir les informations ?",
|
||||
"settings.telegramHelpBot": "Ouvrez Telegram, recherchez <b>@BotFather</b>, envoyez <code>/newbot</code> et suivez les instructions. Copiez le token fourni.",
|
||||
"settings.telegramHelpChat": "Envoyez un message à votre bot, puis ouvrez <code>https://api.telegram.org/bot<TOKEN>/getUpdates</code> dans votre navigateur. Le <b>chat id</b> apparaît dans <code>message.chat.id</code>.",
|
||||
|
||||
Reference in New Issue
Block a user