Show live download progress and Mbps between status and target.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
a4bb2c591a
commit
f1e927726c
4 changed files with 155 additions and 5 deletions
|
|
@ -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}
|
||||
</span>
|
||||
</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">
|
||||
{j.movieTitle}
|
||||
{j.movieYear ? ` (${j.movieYear})` : ""}
|
||||
|
|
@ -648,6 +694,7 @@ export default function DownloadsPage() {
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Voortgang</th>
|
||||
<th>Doel</th>
|
||||
<th>Titel</th>
|
||||
<th>Bron</th>
|
||||
|
|
@ -661,6 +708,14 @@ export default function DownloadsPage() {
|
|||
<td>
|
||||
<span className={statusClass(j.status)}>{statusLabel(j.status)}</span>
|
||||
</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>
|
||||
{j.movieTitle}
|
||||
|
|
@ -685,7 +740,7 @@ export default function DownloadsPage() {
|
|||
))}
|
||||
{activeJobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="muted">
|
||||
<td colSpan={7} className="muted">
|
||||
Geen actieve downloads.
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function mapJob(j: Awaited<ReturnType<DownloadService["listJobs"]>>[number]) {
|
|||
error: j.error,
|
||||
createdAt: j.createdAt,
|
||||
updatedAt: j.updatedAt,
|
||||
progress: j.progress,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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) {
|
||||
|
|
@ -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<void> {
|
|||
|
||||
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 = () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue