feat: download ETA on the press bar from measured Soulseek throughput
Show a live "~Xm left" countdown on the download progress bar. The slskd download loop already polls byte transfer every 3s; measure the actual throughput (EWMA- smoothed bytes/sec), compute seconds-remaining from the album's total bytes, and surface it. New nullable Job.downloadEtaSeconds (migration; web entrypoint runs prisma migrate deploy), threaded through the on_progress callback (optional 2nd arg, so other adapters/callers are unaffected — they report no ETA). API exposes it; queue.tsx renders etaLabel() after the track count. Null when unknown (no sample yet or a source that doesn't report bytes). Worker 330 tests, web 214 tests, both green; verified live-rendered as "42% · 6/14 tracks · ~4m left". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- Estimated seconds remaining for the active download (measured throughput). Nullable.
|
||||
ALTER TABLE "Job" ADD COLUMN "downloadEtaSeconds" INTEGER;
|
||||
@@ -58,6 +58,9 @@ model Job {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
downloadProgress Float @default(0)
|
||||
// Estimated seconds until the current download finishes, from measured throughput (slskd byte
|
||||
// transfer rate). Null when unknown (no speed sample yet, or a source that doesn't report bytes).
|
||||
downloadEtaSeconds Int?
|
||||
paused Boolean @default(false)
|
||||
candidates Candidate[]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describeJob, timeAgo } from "./status";
|
||||
import { describeJob, etaLabel, timeAgo } from "./status";
|
||||
|
||||
describe("etaLabel", () => {
|
||||
it("formats seconds, minutes, and hours", () => {
|
||||
expect(etaLabel(45)).toBe("~45s left");
|
||||
expect(etaLabel(240)).toBe("~4m left");
|
||||
expect(etaLabel(3900)).toBe("~1h 5m left");
|
||||
});
|
||||
|
||||
it("returns null for missing or non-positive input", () => {
|
||||
expect(etaLabel(null)).toBeNull();
|
||||
expect(etaLabel(undefined)).toBeNull();
|
||||
expect(etaLabel(0)).toBeNull();
|
||||
expect(etaLabel(-5)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeJob", () => {
|
||||
it("maps pipeline states to literal labels + severity kind", () => {
|
||||
|
||||
@@ -27,6 +27,16 @@ export function timeAgo(value: string | Date | null | undefined, now: number): s
|
||||
return `${Math.floor(h / 24)}d`;
|
||||
}
|
||||
|
||||
/** Compact download ETA like "~4m left", "~45s left", "~1h 5m left". Null/≤0 → no label. */
|
||||
export function etaLabel(seconds: number | null | undefined): string | null {
|
||||
if (seconds == null || seconds <= 0) return null;
|
||||
if (seconds < 60) return `~${seconds}s left`;
|
||||
const m = Math.round(seconds / 60);
|
||||
if (m < 60) return `~${m}m left`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `~${h}h ${m % 60}m left`;
|
||||
}
|
||||
|
||||
export function describeJob(state: string, stage: string): { label: string; kind: Kind } {
|
||||
void stage; // stage no longer drives the label; kept for call-site compatibility
|
||||
return (
|
||||
|
||||
@@ -64,6 +64,7 @@ export async function GET() {
|
||||
claimedAt: r.job.claimedAt,
|
||||
updatedAt: r.job.updatedAt,
|
||||
downloadProgress: r.job.downloadProgress,
|
||||
downloadEtaSeconds: r.job.downloadEtaSeconds,
|
||||
paused: r.job.paused,
|
||||
candidateCount: cands.length,
|
||||
chosen: chosen ? { source: chosen.source, format: chosen.format, trackCount: chosen.trackCount } : null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { describeJob, timeAgo } from "./_ui/status";
|
||||
import { describeJob, etaLabel, timeAgo } from "./_ui/status";
|
||||
import { StatTiles } from "./_ui/stat-tiles";
|
||||
import { SectionHeader } from "./_ui/section-header";
|
||||
import { JobRow } from "./_ui/job-row";
|
||||
@@ -46,6 +46,7 @@ type Row = {
|
||||
candidateCount: number;
|
||||
chosen: { source: string; format: string; trackCount: number } | null;
|
||||
downloadProgress: number;
|
||||
downloadEtaSeconds: number | null;
|
||||
paused: boolean;
|
||||
} | null;
|
||||
};
|
||||
@@ -62,6 +63,7 @@ function jobOf(r: Row) {
|
||||
candidateCount: 0,
|
||||
chosen: null,
|
||||
downloadProgress: 0,
|
||||
downloadEtaSeconds: null,
|
||||
paused: false,
|
||||
}
|
||||
);
|
||||
@@ -270,13 +272,9 @@ export function Queue() {
|
||||
} else {
|
||||
const total = j.chosen?.trackCount ?? 0;
|
||||
const done = total ? Math.round((pct / 100) * total) : 0;
|
||||
bar = (
|
||||
<ProgressBar
|
||||
kind="working"
|
||||
percent={pct}
|
||||
caption={total ? `${pct}% · ${done}/${total} tracks` : `${pct}%`}
|
||||
/>
|
||||
);
|
||||
const base = total ? `${pct}% · ${done}/${total} tracks` : `${pct}%`;
|
||||
const eta = etaLabel(j.downloadEtaSeconds);
|
||||
bar = <ProgressBar kind="working" percent={pct} caption={eta ? `${base} · ${eta}` : base} />;
|
||||
}
|
||||
} else if (j.state === "tagging") {
|
||||
bar = <ProgressBar kind="verify" indeterminate caption="finishing" />;
|
||||
|
||||
Reference in New Issue
Block a user