From f1e927726c41294a0008b9540e31d0c79e3ba816 Mon Sep 17 00:00:00 2001 From: Jos Vooges | STH Date: Tue, 8 Sep 2026 01:51:28 +0200 Subject: [PATCH] Show live download progress and Mbps between status and target. Co-authored-by: Cursor --- apps/admin-ui/src/app/downloads/page.tsx | 61 +++++++++++++++++++- apps/admin-ui/src/app/globals.css | 26 +++++++++ apps/master-api/src/downloads/routes.ts | 1 + apps/master-api/src/downloads/service.ts | 72 +++++++++++++++++++++++- 4 files changed, 155 insertions(+), 5 deletions(-) diff --git a/apps/admin-ui/src/app/downloads/page.tsx b/apps/admin-ui/src/app/downloads/page.tsx index 41f5def..74cf83f 100644 --- a/apps/admin-ui/src/app/downloads/page.tsx +++ b/apps/admin-ui/src/app/downloads/page.tsx @@ -45,6 +45,14 @@ interface SubtitleHit { source?: "com" | "org"; } +interface JobProgress { + percent: number | null; + bytesDownloaded: number | null; + bytesTotal: number | null; + speedBps: number | null; + dsStatus: string | null; +} + interface JobRow { id: string; nodeId: string; @@ -63,6 +71,7 @@ interface JobRow { subtitleError?: string | null; subtitleSource?: string | null; importedPath?: string | null; + progress?: JobProgress | null; } type LibraryTarget = "FILMS" | "FOUR_K" | "SERIES"; @@ -77,6 +86,26 @@ function targetLabel(t?: string): string { return TARGET_OPTIONS.find((o) => o.id === t)?.label || t || "4K"; } +function formatMbps(bps: number | null | undefined): string { + if (bps == null || bps < 0) return "—"; + if (bps === 0) return "0 Mbps"; + const mbps = (bps * 8) / 1_000_000; + if (mbps >= 10) return `${mbps.toFixed(0)} Mbps`; + if (mbps >= 1) return `${mbps.toFixed(1)} Mbps`; + return `${(mbps * 1000).toFixed(0)} Kbps`; +} + +function progressLabel(job: JobRow): string { + if (job.status === "IMPORTING" || job.status === "FINISHED") return "Importeren…"; + if (job.status !== "DOWNLOADING") return "—"; + const p = job.progress; + if (!p) return "…"; + const parts: string[] = []; + if (p.percent != null) parts.push(`${p.percent % 1 === 0 ? p.percent.toFixed(0) : p.percent.toFixed(1)}%`); + parts.push(formatMbps(p.speedBps)); + return parts.join(" · "); +} + function formatBytes(n: number): string { if (!n || n <= 0) return "—"; const u = ["B", "KB", "MB", "GB", "TB"]; @@ -180,9 +209,18 @@ export default function DownloadsPage() { useEffect(() => { loadBootstrap(); - const t = setInterval(loadJobs, 15_000); + }, [loadBootstrap]); + + const hasDownloading = useMemo( + () => jobs.some((j) => j.status === "DOWNLOADING" || j.status === "IMPORTING" || j.status === "FINISHED"), + [jobs] + ); + + useEffect(() => { + const ms = hasDownloading ? 4_000 : 15_000; + const t = setInterval(loadJobs, ms); return () => clearInterval(t); - }, [loadBootstrap, loadJobs]); + }, [loadJobs, hasDownloading]); const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]); const configuredNodes = useMemo(() => { @@ -619,6 +657,14 @@ export default function DownloadsPage() { {targetLabel(j.libraryTarget)} · {j.nodeName} +
+
{progressLabel(j)}
+ {j.status === "DOWNLOADING" && j.progress?.percent != null && ( +
+ +
+ )} +
{j.movieTitle} {j.movieYear ? ` (${j.movieYear})` : ""} @@ -648,6 +694,7 @@ export default function DownloadsPage() { Status + Voortgang Doel Titel Bron @@ -661,6 +708,14 @@ export default function DownloadsPage() { {statusLabel(j.status)} + +
{progressLabel(j)}
+ {j.status === "DOWNLOADING" && j.progress?.percent != null && ( +
+ +
+ )} + {targetLabel(j.libraryTarget)} {j.movieTitle} @@ -685,7 +740,7 @@ export default function DownloadsPage() { ))} {activeJobs.length === 0 && ( - + Geen actieve downloads. diff --git a/apps/admin-ui/src/app/globals.css b/apps/admin-ui/src/app/globals.css index b25437d..ba615bb 100644 --- a/apps/admin-ui/src/app/globals.css +++ b/apps/admin-ui/src/app/globals.css @@ -1418,6 +1418,32 @@ tr.row-selected { gap: 0.35rem; justify-content: flex-end; } +.downloads-progress-cell { + min-width: 7.5rem; + max-width: 10rem; +} +.downloads-progress-block { + margin: 0.35rem 0 0.45rem; +} +.downloads-progress-meta { + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + color: var(--text-bright, var(--text)); +} +.downloads-progress-bar { + margin-top: 0.3rem; + height: 4px; + border-radius: 999px; + background: var(--border-dim); + overflow: hidden; +} +.downloads-progress-bar > span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--accent, #3b82f6); + transition: width 0.4s ease; +} .downloads-toolbar { display: flex; flex-wrap: wrap; diff --git a/apps/master-api/src/downloads/routes.ts b/apps/master-api/src/downloads/routes.ts index 2b26e1b..c449381 100644 --- a/apps/master-api/src/downloads/routes.ts +++ b/apps/master-api/src/downloads/routes.ts @@ -29,6 +29,7 @@ function mapJob(j: Awaited>[number]) { error: j.error, createdAt: j.createdAt, updatedAt: j.updatedAt, + progress: j.progress, }; } diff --git a/apps/master-api/src/downloads/service.ts b/apps/master-api/src/downloads/service.ts index 6e20743..9136d76 100644 --- a/apps/master-api/src/downloads/service.ts +++ b/apps/master-api/src/downloads/service.ts @@ -662,11 +662,49 @@ export class DownloadService { } async listJobs(limit = 50) { - return prisma.downloadJob.findMany({ + const jobs = await prisma.downloadJob.findMany({ take: Math.min(200, Math.max(1, limit)), orderBy: { createdAt: "desc" }, include: { node: { select: { id: true, name: true } } }, }); + + const downloading = jobs.filter((j) => j.status === "DOWNLOADING"); + const progressByJobId = new Map(); + + const byNode = new Map(); + for (const job of downloading) { + const list = byNode.get(job.nodeId) ?? []; + list.push(job); + byNode.set(job.nodeId, list); + } + + for (const [nodeId, nodeJobs] of byNode) { + try { + const { client } = await this.clientForNode(nodeId); + const tasks = await client.listTasks(); + const byId = new Map(tasks.map((t) => [t.id, t])); + for (const job of nodeJobs) { + const task = resolveTask(job.dsTaskId, job.sourceTitle, job.sourceUri, byId, tasks); + if (!task) continue; + progressByJobId.set(job.id, progressFromTask(task)); + if (!job.dsTaskId && task.id) { + void prisma.downloadJob + .update({ where: { id: job.id }, data: { dsTaskId: task.id } }) + .catch(() => undefined); + } + } + } catch (err) { + console.warn( + `[downloads] live progress node ${nodeId}:`, + err instanceof Error ? err.message : err + ); + } + } + + return jobs.map((j) => ({ + ...j, + progress: progressByJobId.get(j.id) ?? null, + })); } async deleteJob(jobId: string) { @@ -756,6 +794,8 @@ export class DownloadService { continue; } + // Keep polling for finish; progress is read live via listJobs. + if (isTaskDone(task.status)) { const sourcePath = guessSourcePath(config.downloadHostPath, task, job.sourceTitle); await prisma.downloadJob.update({ @@ -1106,6 +1146,34 @@ function isTaskDone(status: string): boolean { return status === "finished" || status === "seeding"; } +export type JobProgress = { + percent: number | null; + bytesDownloaded: number | null; + bytesTotal: number | null; + /** Bytes per second from Download Station */ + speedBps: number | null; + dsStatus: string | null; +}; + +function progressFromTask(task: DsTask): JobProgress { + const size = typeof task.size === "number" && task.size > 0 ? task.size : 0; + const downloaded = task.additional?.transfer?.size_downloaded ?? 0; + const speed = task.additional?.transfer?.speed_download ?? 0; + let percent: number | null = null; + if (size > 0) { + percent = Math.min(100, Math.round((downloaded / size) * 1000) / 10); + } else if (isTaskDone(task.status) || task.status === "finishing") { + percent = 100; + } + return { + percent, + bytesDownloaded: downloaded > 0 ? downloaded : downloaded === 0 && size > 0 ? 0 : null, + bytesTotal: size > 0 ? size : null, + speedBps: speed > 0 ? speed : speed === 0 ? 0 : null, + dsStatus: task.status || null, + }; +} + function uriLikelyMatch(task: DsTask, uri: string, sourceTitle: string): boolean { const detailUri = task.additional?.detail?.uri || ""; if (uri && detailUri && (detailUri === uri || detailUri.includes(uri.slice(0, 48)))) return true; @@ -1150,7 +1218,7 @@ function sleep(ms: number): Promise { let pollerStarted = false; -export function startDownloadPoller(service: DownloadService, intervalMs = 30_000): void { +export function startDownloadPoller(service: DownloadService, intervalMs = 10_000): void { if (pollerStarted) return; pollerStarted = true; const tick = () => {