"use client"; import { useState, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { TorrentDownloadDto, LatestFoundPerLibraryDto } from "@/lib/api"; import { Card, CardContent, CardHeader, CardTitle, Button, Icon } from "@/app/components/ui"; import { QbittorrentProvider, QbittorrentDownloadButton } from "@/app/components/QbittorrentDownloadButton"; import { useTranslation } from "@/lib/i18n/context"; import type { TranslationKey } from "@/lib/i18n/fr"; type TFunction = (key: TranslationKey, vars?: Record) => string; const STATUS_ACTIVE = new Set(["downloading", "completed", "importing"]); function statusLabel(status: string, t: TFunction): string { const map: Record = { 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", }); } function formatSpeed(bytesPerSec: number): string { if (bytesPerSec < 1024) return `${bytesPerSec} B/s`; if (bytesPerSec < 1024 * 1024) return `${(bytesPerSec / 1024).toFixed(1)} KB/s`; return `${(bytesPerSec / 1024 / 1024).toFixed(1)} MB/s`; } function formatEta(seconds: number): string { if (seconds <= 0 || seconds >= 8640000) return ""; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`; if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`; return `${s}s`; } interface DownloadsPageProps { initialDownloads: TorrentDownloadDto[]; initialLatestFound: LatestFoundPerLibraryDto[]; } const PAGE_SIZE = 10; export function DownloadsPage({ initialDownloads, initialLatestFound }: DownloadsPageProps) { const { t } = useTranslation(); const [downloads, setDownloads] = useState(initialDownloads); const [filter, setFilter] = useState("all"); const [isRefreshing, setIsRefreshing] = useState(false); const [page, setPage] = useState(1); 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), 2000); 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; }); const totalPages = Math.ceil(visible.length / PAGE_SIZE); const paged = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); // Reset to page 1 when filter changes const handleFilterChange = (id: string) => { setFilter(id); setPage(1); }; return ( <>

{t("downloads.title")}

{/* Filter bar */}
{filters.map(f => ( ))}
{visible.length === 0 ? (

{t("downloads.empty")}

) : ( <>
{paged.map(dl => ( refresh(false)} /> ))}
{totalPages > 1 && (
{page} / {totalPages}
)} )} {/* Available downloads from latest detection */} {initialLatestFound.length > 0 && (

{t("downloads.availableTitle")}

{initialLatestFound.map(lib => ( ))}
)} ); } function DownloadRow({ dl, onDeleted }: { dl: TorrentDownloadDto; onDeleted: () => void }) { const { t } = useTranslation(); const [deleting, setDeleting] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const importedCount = Array.isArray(dl.imported_files) ? dl.imported_files.length : 0; async function handleDelete() { setDeleting(true); setShowConfirm(false); try { const resp = await fetch(`/api/torrent-downloads/${dl.id}`, { method: "DELETE" }); if (resp.ok) onDeleted(); } finally { setDeleting(false); } } const statusIcon = dl.status === "importing" ? ( ) : dl.status === "imported" ? ( ) : dl.status === "error" ? ( ) : dl.status === "downloading" ? ( ) : ( ); return ( <>
{statusIcon}
{dl.series_name} {statusLabel(dl.status, t)} {dl.expected_volumes.length > 0 && ( {formatVolumes(dl.expected_volumes)} )} {dl.status === "imported" && importedCount > 0 && ( {importedCount} {t("downloads.filesImported")} )}
{dl.status === "downloading" && (
{Math.round(dl.progress * 100)}% {dl.download_speed > 0 && ( {formatSpeed(dl.download_speed)} )} {dl.eta > 0 && dl.eta < 8640000 && ( ETA {formatEta(dl.eta)} )}
)} {dl.error_message && (

{dl.error_message}

)}
{formatDate(dl.created_at)}
{showConfirm && createPortal( <>
setShowConfirm(false)} />

{dl.status === "downloading" ? t("downloads.cancel") : t("downloads.delete")}

{dl.status === "downloading" ? t("downloads.confirmCancel") : t("downloads.confirmDelete")}

, document.body )} ); } function AvailableLibraryCard({ lib }: { lib: LatestFoundPerLibraryDto }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState(true); const displayResults = collapsed ? lib.results.slice(0, 5) : lib.results; return (
{lib.library_name} {t("downloads.detectedSeries", { count: lib.results.length })} — {formatDate(lib.job_date)}
{displayResults.map(r => (
{r.series_name} {r.missing_count} {t("downloads.missing")}
{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} seeders )} {(release.size / 1024 / 1024).toFixed(0)} MB
{release.matched_missing_volumes.map(vol => ( T.{vol} ))}
{release.download_url && ( )}
))}
)}
))} {lib.results.length > 5 && ( )}
); }