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 = () => {