feat: gestion des téléchargements qBittorrent avec import automatique
- Nouvelle table `torrent_downloads` pour suivre les téléchargements gérés - API : endpoint POST /torrent-downloads/notify (webhook optionnel) et GET /torrent-downloads - Poller background toutes les 30s qui interroge qBittorrent pour détecter les torrents terminés — aucune config "run external program" nécessaire - Import automatique : déplacement des fichiers vers la série cible, renommage selon le pattern existant (détection de la largeur des digits), support packs multi-volumes, scan job déclenché après import - Page /downloads dans le backoffice : filtres, auto-refresh, carte par download - Toggle auto-import intégré dans la card qBittorrent des settings - Erreurs de détection download affichées dans le détail des jobs - Volume /downloads monté dans docker-compose Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
222
apps/backoffice/app/(app)/downloads/DownloadsPage.tsx
Normal file
222
apps/backoffice/app/(app)/downloads/DownloadsPage.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { TorrentDownloadDto } from "@/lib/api";
|
||||
import { Card, CardContent, Button, Icon } from "@/app/components/ui";
|
||||
import { useTranslation } from "@/lib/i18n/context";
|
||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||
|
||||
type TFunction = (key: TranslationKey, vars?: Record<string, string | number>) => string;
|
||||
|
||||
const STATUS_ACTIVE = new Set(["downloading", "completed", "importing"]);
|
||||
|
||||
function statusLabel(status: string, t: TFunction): string {
|
||||
const map: Record<string, TranslationKey> = {
|
||||
downloading: "downloads.status.downloading",
|
||||
completed: "downloads.status.completed",
|
||||
importing: "downloads.status.importing",
|
||||
imported: "downloads.status.imported",
|
||||
error: "downloads.status.error",
|
||||
};
|
||||
return t(map[status] ?? "downloads.status.error");
|
||||
}
|
||||
|
||||
function statusClass(status: string): string {
|
||||
switch (status) {
|
||||
case "downloading": return "bg-primary/10 text-primary";
|
||||
case "completed": return "bg-warning/10 text-warning";
|
||||
case "importing": return "bg-primary/10 text-primary";
|
||||
case "imported": return "bg-success/10 text-success";
|
||||
case "error": return "bg-destructive/10 text-destructive";
|
||||
default: return "bg-muted/30 text-muted-foreground";
|
||||
}
|
||||
}
|
||||
|
||||
function formatVolumes(vols: number[]): string {
|
||||
return [...vols].sort((a, b) => a - b).map(v => `T${String(v).padStart(2, "0")}`).join(", ");
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString("fr-FR", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
interface DownloadsPageProps {
|
||||
initialDownloads: TorrentDownloadDto[];
|
||||
}
|
||||
|
||||
export function DownloadsPage({ initialDownloads }: DownloadsPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const [downloads, setDownloads] = useState<TorrentDownloadDto[]>(initialDownloads);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const refresh = useCallback(async (showSpinner = true) => {
|
||||
if (showSpinner) setIsRefreshing(true);
|
||||
try {
|
||||
const resp = await fetch("/api/torrent-downloads");
|
||||
if (resp.ok) setDownloads(await resp.json());
|
||||
} finally {
|
||||
if (showSpinner) setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-refresh every 5s while there are active downloads
|
||||
const hasActive = downloads.some(d => STATUS_ACTIVE.has(d.status));
|
||||
useEffect(() => {
|
||||
if (!hasActive) return;
|
||||
const id = setInterval(() => refresh(false), 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [hasActive, refresh]);
|
||||
|
||||
const filters = [
|
||||
{ id: "all", label: t("common.all") },
|
||||
{ id: "active", label: t("downloads.filterActive") },
|
||||
{ id: "imported", label: t("downloads.status.imported") },
|
||||
{ id: "error", label: t("downloads.status.error") },
|
||||
];
|
||||
|
||||
const visible = downloads.filter(d => {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "active") return STATUS_ACTIVE.has(d.status);
|
||||
return d.status === filter;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-3">
|
||||
<Icon name="download" size="xl" />
|
||||
{t("downloads.title")}
|
||||
</h1>
|
||||
<Button onClick={() => refresh(true)} disabled={isRefreshing} variant="outline" size="sm">
|
||||
{isRefreshing ? (
|
||||
<Icon name="spinner" size="sm" className="animate-spin" />
|
||||
) : (
|
||||
<Icon name="refresh" size="sm" />
|
||||
)}
|
||||
<span className="ml-2">{t("downloads.refresh")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="flex gap-1 mb-4 border-b border-border">
|
||||
{filters.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||||
filter === f.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{f.id !== "all" && (
|
||||
<span className="ml-1.5 text-xs opacity-60">
|
||||
{downloads.filter(d => f.id === "active" ? STATUS_ACTIVE.has(d.status) : d.status === f.id).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
<Card className="mt-4">
|
||||
<CardContent className="pt-16 pb-16 flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Icon name="download" size="xl" className="opacity-30" />
|
||||
<p className="text-sm">{t("downloads.empty")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{visible.map(dl => (
|
||||
<DownloadCard key={dl.id} dl={dl} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadCard({ dl }: { dl: TorrentDownloadDto }) {
|
||||
const { t } = useTranslation();
|
||||
const importedCount = Array.isArray(dl.imported_files) ? dl.imported_files.length : 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Status indicator */}
|
||||
<div className="mt-0.5">
|
||||
{dl.status === "importing" ? (
|
||||
<Icon name="spinner" size="md" className="animate-spin text-primary" />
|
||||
) : dl.status === "imported" ? (
|
||||
<Icon name="check" size="md" className="text-success" />
|
||||
) : dl.status === "error" ? (
|
||||
<Icon name="warning" size="md" className="text-destructive" />
|
||||
) : dl.status === "downloading" ? (
|
||||
<Icon name="download" size="md" className="text-primary" />
|
||||
) : (
|
||||
<Icon name="refresh" size="md" className="text-warning" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-foreground truncate">{dl.series_name}</span>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${statusClass(dl.status)}`}>
|
||||
{statusLabel(dl.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center gap-4 text-sm text-muted-foreground flex-wrap">
|
||||
{dl.expected_volumes.length > 0 && (
|
||||
<span>{t("downloads.volumes")} : {formatVolumes(dl.expected_volumes)}</span>
|
||||
)}
|
||||
{dl.status === "imported" && importedCount > 0 && (
|
||||
<span className="text-success">{importedCount} {t("downloads.filesImported")}</span>
|
||||
)}
|
||||
{dl.qb_hash && (
|
||||
<span className="font-mono text-xs opacity-50" title={dl.qb_hash}>
|
||||
{dl.qb_hash.slice(0, 8)}…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{dl.content_path && dl.status !== "imported" && (
|
||||
<p className="mt-1 text-xs font-mono text-muted-foreground truncate" title={dl.content_path}>
|
||||
{dl.content_path}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{dl.error_message && (
|
||||
<p className="mt-1 text-sm text-destructive">{dl.error_message}</p>
|
||||
)}
|
||||
|
||||
{dl.status === "imported" && Array.isArray(dl.imported_files) && dl.imported_files.length > 0 && (
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{(dl.imported_files as Array<{ volume: number; destination: string }>).map((f, i) => (
|
||||
<li key={i} className="text-xs text-muted-foreground font-mono truncate" title={f.destination}>
|
||||
T{String(f.volume).padStart(2, "0")} → {f.destination.split("/").pop()}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs text-muted-foreground shrink-0 text-right">
|
||||
<p>{formatDate(dl.created_at)}</p>
|
||||
{dl.updated_at !== dl.created_at && (
|
||||
<p className="opacity-60">maj {formatDate(dl.updated_at)}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
9
apps/backoffice/app/(app)/downloads/page.tsx
Normal file
9
apps/backoffice/app/(app)/downloads/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { fetchTorrentDownloads, TorrentDownloadDto } from "@/lib/api";
|
||||
import { DownloadsPage } from "./DownloadsPage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page() {
|
||||
const downloads = await fetchTorrentDownloads().catch(() => [] as TorrentDownloadDto[]);
|
||||
return <DownloadsPage initialDownloads={downloads} />;
|
||||
}
|
||||
@@ -24,6 +24,30 @@ export function DownloadDetectionReportCard({ report, t }: { report: DownloadDet
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadDetectionErrorsCard({ results, t }: {
|
||||
results: DownloadDetectionResultDto[];
|
||||
t: TranslateFunction;
|
||||
}) {
|
||||
if (results.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("jobDetail.downloadErrors")}</CardTitle>
|
||||
<CardDescription>{t("jobDetail.downloadErrorsDesc", { count: String(results.length) })}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{results.map((r) => (
|
||||
<div key={r.id} className="p-3 bg-destructive/10 rounded-lg border border-destructive/20">
|
||||
<p className="text-sm font-semibold text-destructive mb-1">{r.series_name}</p>
|
||||
<p className="text-sm text-destructive/80">{r.error_message ?? "Erreur inconnue"}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function DownloadDetectionResultsCard({ results, libraryId, t }: {
|
||||
results: DownloadDetectionResultDto[];
|
||||
libraryId: string | null;
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { DownloadDetectionReportCard, DownloadDetectionResultsCard, DownloadDetectionErrorsCard } from "./components/DownloadDetectionCards";
|
||||
import { JobErrorsCard } from "./components/JobErrorsCard";
|
||||
|
||||
interface JobDetailPageProps {
|
||||
@@ -148,10 +148,12 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
|
||||
let downloadDetectionReport: DownloadDetectionReportDto | null = null;
|
||||
let downloadDetectionResults: DownloadDetectionResultDto[] = [];
|
||||
let downloadDetectionErrors: DownloadDetectionResultDto[] = [];
|
||||
if (isDownloadDetection) {
|
||||
[downloadDetectionReport, downloadDetectionResults] = await Promise.all([
|
||||
[downloadDetectionReport, downloadDetectionResults, downloadDetectionErrors] = await Promise.all([
|
||||
getDownloadDetectionReport(id).catch(() => null),
|
||||
getDownloadDetectionResults(id, "found").catch(() => []),
|
||||
getDownloadDetectionResults(id, "error").catch(() => []),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -270,6 +272,7 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) {
|
||||
|
||||
{/* Download detection */}
|
||||
{isDownloadDetection && downloadDetectionReport && <DownloadDetectionReportCard report={downloadDetectionReport} t={t} />}
|
||||
{isDownloadDetection && <DownloadDetectionErrorsCard results={downloadDetectionErrors} t={t} />}
|
||||
{isDownloadDetection && <DownloadDetectionResultsCard results={downloadDetectionResults} libraryId={job.library_id} t={t} />}
|
||||
|
||||
{/* Metadata batch results */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { ThemeToggle } from "@/app/theme-toggle";
|
||||
import { JobsIndicator } from "@/app/components/JobsIndicator";
|
||||
import { NavIcon, Icon } from "@/app/components/ui";
|
||||
import { NavLink } from "@/app/components/NavLink";
|
||||
import { LogoutButton } from "@/app/components/LogoutButton";
|
||||
import { MobileNav } from "@/app/components/MobileNav";
|
||||
import { UserSwitcher } from "@/app/components/UserSwitcher";
|
||||
@@ -14,9 +15,9 @@ import { getServerTranslations } from "@/lib/i18n/server";
|
||||
import type { TranslationKey } from "@/lib/i18n/fr";
|
||||
|
||||
type NavItem = {
|
||||
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings";
|
||||
href: "/" | "/books" | "/series" | "/authors" | "/libraries" | "/jobs" | "/tokens" | "/settings" | "/downloads";
|
||||
labelKey: TranslationKey;
|
||||
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings";
|
||||
icon: "dashboard" | "books" | "series" | "authors" | "libraries" | "jobs" | "tokens" | "settings" | "download";
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
@@ -24,6 +25,7 @@ const navItems: NavItem[] = [
|
||||
{ href: "/series", labelKey: "nav.series", icon: "series" },
|
||||
{ href: "/authors", labelKey: "nav.authors", icon: "authors" },
|
||||
{ href: "/libraries", labelKey: "nav.libraries", icon: "libraries" },
|
||||
{ href: "/downloads", labelKey: "nav.downloads", icon: "download" },
|
||||
{ href: "/jobs", labelKey: "nav.jobs", icon: "jobs" },
|
||||
{ href: "/tokens", labelKey: "nav.tokens", icon: "tokens" },
|
||||
];
|
||||
@@ -113,24 +115,3 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ href, title, children }: { href: NavItem["href"]; title?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
title={title}
|
||||
className="
|
||||
flex items-center
|
||||
px-2 lg:px-3 py-2
|
||||
rounded-lg
|
||||
text-sm font-medium
|
||||
text-muted-foreground
|
||||
hover:text-foreground
|
||||
hover:bg-accent
|
||||
transition-colors duration-200
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, Icon } from "@/app/components/ui";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, Button, FormField, FormInput, FormSelect, Icon } from "@/app/components/ui";
|
||||
import { useTranslation } from "@/lib/i18n/context";
|
||||
|
||||
export function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting: (key: string, value: unknown) => Promise<void> }) {
|
||||
@@ -11,6 +11,7 @@ export function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting:
|
||||
const [qbPassword, setQbPassword] = useState("");
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [importEnabled, setImportEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings/qbittorrent")
|
||||
@@ -23,6 +24,10 @@ export function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting:
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
fetch("/api/settings/torrent_import")
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((data) => { if (data?.enabled !== undefined) setImportEnabled(data.enabled); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
function saveQbittorrent() {
|
||||
@@ -118,6 +123,32 @@ export function QBittorrentCard({ handleUpdateSetting }: { handleUpdateSetting:
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/40 pt-4">
|
||||
<FormField className="max-w-xs">
|
||||
<label className="text-sm font-medium text-muted-foreground mb-1 block">
|
||||
{t("settings.torrentImportEnabled")}
|
||||
</label>
|
||||
<FormSelect
|
||||
value={importEnabled ? "true" : "false"}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === "true";
|
||||
setImportEnabled(val);
|
||||
handleUpdateSetting("torrent_import", { enabled: val });
|
||||
}}
|
||||
>
|
||||
<option value="false">{t("common.disabled")}</option>
|
||||
<option value="true">{t("common.enabled")}</option>
|
||||
</FormSelect>
|
||||
</FormField>
|
||||
|
||||
{importEnabled && (
|
||||
<div className="mt-3 rounded-lg border border-success/20 bg-success/5 p-3 flex items-start gap-2">
|
||||
<Icon name="check" size="sm" className="text-success mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-muted-foreground">{t("settings.torrentImportPollingInfo")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user