Show live download progress and Mbps between status and target.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-09-08 01:51:28 +02:00
parent a4bb2c591a
commit f1e927726c
4 changed files with 155 additions and 5 deletions

View file

@ -45,6 +45,14 @@ interface SubtitleHit {
source?: "com" | "org"; source?: "com" | "org";
} }
interface JobProgress {
percent: number | null;
bytesDownloaded: number | null;
bytesTotal: number | null;
speedBps: number | null;
dsStatus: string | null;
}
interface JobRow { interface JobRow {
id: string; id: string;
nodeId: string; nodeId: string;
@ -63,6 +71,7 @@ interface JobRow {
subtitleError?: string | null; subtitleError?: string | null;
subtitleSource?: string | null; subtitleSource?: string | null;
importedPath?: string | null; importedPath?: string | null;
progress?: JobProgress | null;
} }
type LibraryTarget = "FILMS" | "FOUR_K" | "SERIES"; 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"; 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 { function formatBytes(n: number): string {
if (!n || n <= 0) return "—"; if (!n || n <= 0) return "—";
const u = ["B", "KB", "MB", "GB", "TB"]; const u = ["B", "KB", "MB", "GB", "TB"];
@ -180,9 +209,18 @@ export default function DownloadsPage() {
useEffect(() => { useEffect(() => {
loadBootstrap(); 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); return () => clearInterval(t);
}, [loadBootstrap, loadJobs]); }, [loadJobs, hasDownloading]);
const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]); const hasConfig = useMemo(() => configs.some((c) => c.nodeId === nodeId), [configs, nodeId]);
const configuredNodes = useMemo(() => { const configuredNodes = useMemo(() => {
@ -619,6 +657,14 @@ export default function DownloadsPage() {
{targetLabel(j.libraryTarget)} · {j.nodeName} {targetLabel(j.libraryTarget)} · {j.nodeName}
</span> </span>
</div> </div>
<div className="downloads-progress-block">
<div className="downloads-progress-meta">{progressLabel(j)}</div>
{j.status === "DOWNLOADING" && j.progress?.percent != null && (
<div className="downloads-progress-bar" aria-hidden>
<span style={{ width: `${Math.min(100, j.progress.percent)}%` }} />
</div>
)}
</div>
<div className="list-card-title"> <div className="list-card-title">
{j.movieTitle} {j.movieTitle}
{j.movieYear ? ` (${j.movieYear})` : ""} {j.movieYear ? ` (${j.movieYear})` : ""}
@ -648,6 +694,7 @@ export default function DownloadsPage() {
<thead> <thead>
<tr> <tr>
<th>Status</th> <th>Status</th>
<th>Voortgang</th>
<th>Doel</th> <th>Doel</th>
<th>Titel</th> <th>Titel</th>
<th>Bron</th> <th>Bron</th>
@ -661,6 +708,14 @@ export default function DownloadsPage() {
<td> <td>
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span> <span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
</td> </td>
<td className="downloads-progress-cell">
<div className="downloads-progress-meta">{progressLabel(j)}</div>
{j.status === "DOWNLOADING" && j.progress?.percent != null && (
<div className="downloads-progress-bar" aria-hidden>
<span style={{ width: `${Math.min(100, j.progress.percent)}%` }} />
</div>
)}
</td>
<td className="muted">{targetLabel(j.libraryTarget)}</td> <td className="muted">{targetLabel(j.libraryTarget)}</td>
<td> <td>
{j.movieTitle} {j.movieTitle}
@ -685,7 +740,7 @@ export default function DownloadsPage() {
))} ))}
{activeJobs.length === 0 && ( {activeJobs.length === 0 && (
<tr> <tr>
<td colSpan={6} className="muted"> <td colSpan={7} className="muted">
Geen actieve downloads. Geen actieve downloads.
</td> </td>
</tr> </tr>

View file

@ -1418,6 +1418,32 @@ tr.row-selected {
gap: 0.35rem; gap: 0.35rem;
justify-content: flex-end; 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 { .downloads-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -29,6 +29,7 @@ function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) {
error: j.error, error: j.error,
createdAt: j.createdAt, createdAt: j.createdAt,
updatedAt: j.updatedAt, updatedAt: j.updatedAt,
progress: j.progress,
}; };
} }

View file

@ -662,11 +662,49 @@ export class DownloadService {
} }
async listJobs(limit = 50) { async listJobs(limit = 50) {
return prisma.downloadJob.findMany({ const jobs = await prisma.downloadJob.findMany({
take: Math.min(200, Math.max(1, limit)), take: Math.min(200, Math.max(1, limit)),
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
include: { node: { select: { id: true, name: true } } }, include: { node: { select: { id: true, name: true } } },
}); });
const downloading = jobs.filter((j) => j.status === "DOWNLOADING");
const progressByJobId = new Map<string, JobProgress>();
const byNode = new Map<string, typeof downloading>();
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) { async deleteJob(jobId: string) {
@ -756,6 +794,8 @@ export class DownloadService {
continue; continue;
} }
// Keep polling for finish; progress is read live via listJobs.
if (isTaskDone(task.status)) { if (isTaskDone(task.status)) {
const sourcePath = guessSourcePath(config.downloadHostPath, task, job.sourceTitle); const sourcePath = guessSourcePath(config.downloadHostPath, task, job.sourceTitle);
await prisma.downloadJob.update({ await prisma.downloadJob.update({
@ -1106,6 +1146,34 @@ function isTaskDone(status: string): boolean {
return status === "finished" || status === "seeding"; 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 { function uriLikelyMatch(task: DsTask, uri: string, sourceTitle: string): boolean {
const detailUri = task.additional?.detail?.uri || ""; const detailUri = task.additional?.detail?.uri || "";
if (uri && detailUri && (detailUri === uri || detailUri.includes(uri.slice(0, 48)))) return true; if (uri && detailUri && (detailUri === uri || detailUri.includes(uri.slice(0, 48)))) return true;
@ -1150,7 +1218,7 @@ function sleep(ms: number): Promise<void> {
let pollerStarted = false; let pollerStarted = false;
export function startDownloadPoller(service: DownloadService, intervalMs = 30_000): void { export function startDownloadPoller(service: DownloadService, intervalMs = 10_000): void {
if (pollerStarted) return; if (pollerStarted) return;
pollerStarted = true; pollerStarted = true;
const tick = () => { const tick = () => {