"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[]; } 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 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; }); return ( <>

{t("downloads.title")}

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

{t("downloads.empty")}

) : (
{visible.map(dl => ( refresh(false)} /> ))}
)} {/* Available downloads from latest detection */} {initialLatestFound.length > 0 && (

{t("downloads.availableTitle")}

{initialLatestFound.map(lib => ( ))}
)} ); } function DownloadCard({ 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); } } return (
{/* Status indicator */}
{dl.status === "importing" ? ( ) : dl.status === "imported" ? ( ) : dl.status === "error" ? ( ) : dl.status === "downloading" ? ( ) : ( )}
{/* Main info */}
{dl.series_name} {statusLabel(dl.status, t)}
{dl.expected_volumes.length > 0 && ( {t("downloads.volumes")} : {formatVolumes(dl.expected_volumes)} )} {dl.status === "imported" && importedCount > 0 && ( {importedCount} {t("downloads.filesImported")} )} {dl.qb_hash && ( {dl.qb_hash.slice(0, 8)}… )}
{dl.status === "downloading" && (
{Math.round(dl.progress * 100)}%
{(dl.download_speed > 0 || dl.eta > 0) && (
{dl.download_speed > 0 && {formatSpeed(dl.download_speed)}} {dl.eta > 0 && dl.eta < 8640000 && ETA {formatEta(dl.eta)}}
)}
)} {dl.content_path && dl.status !== "imported" && (

{dl.content_path}

)} {dl.error_message && (

{dl.error_message}

)} {dl.status === "imported" && Array.isArray(dl.imported_files) && dl.imported_files.length > 0 && (
    {(dl.imported_files as Array<{ volume: number; destination: string }>).map((f, i) => (
  • T{String(f.volume).padStart(2, "0")} → {f.destination.split("/").pop()}
  • ))}
)}
{/* Actions */}

{formatDate(dl.created_at)}

{dl.updated_at !== dl.created_at && (

maj {formatDate(dl.updated_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 && ( )}
); }