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:
@@ -29,6 +29,14 @@ def _dirname(path: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _eta_seconds(total_bytes: int, bytes_now: int, speed_bps: float) -> int | None:
|
||||
"""Seconds until the transfer finishes at the current measured rate, or None when it can't be
|
||||
estimated (no throughput sample yet, unknown total, or already complete). Pure — unit-tested."""
|
||||
if speed_bps <= 0 or total_bytes <= 0 or bytes_now >= total_bytes:
|
||||
return None
|
||||
return int((total_bytes - bytes_now) / speed_bps)
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""Lowercase, strip diacritics, and reduce to alphanumeric tokens, so 'Beyoncé' matches a
|
||||
peer's 'Beyonce' folder and punctuation ('I Am… Sasha Fierce') doesn't defeat the match."""
|
||||
@@ -206,9 +214,13 @@ class SlskdClient:
|
||||
on_progress(0.0)
|
||||
|
||||
total = len(files)
|
||||
total_bytes = sum(int(f.get("size") or 0) for f in files)
|
||||
completed = False
|
||||
last_bytes = -1
|
||||
stall = 0
|
||||
speed = 0.0 # bytes/sec, EWMA-smoothed so the ETA doesn't jitter poll to poll
|
||||
prev_bytes = 0
|
||||
prev_t = time.monotonic()
|
||||
for _ in range(_MAX_XFER_POLLS):
|
||||
time.sleep(_POLL_SECONDS)
|
||||
data = self._get(f"/api/v0/transfers/downloads/{username}")
|
||||
@@ -224,8 +236,14 @@ class SlskdClient:
|
||||
if states:
|
||||
if any(bad in s for s in states for bad in ("Errored", "Rejected", "Cancelled")):
|
||||
raise RuntimeError("soulseek transfer failed")
|
||||
now = time.monotonic()
|
||||
dt = now - prev_t
|
||||
if dt > 0 and bytes_now >= prev_bytes:
|
||||
inst = (bytes_now - prev_bytes) / dt
|
||||
speed = inst if speed == 0.0 else 0.6 * speed + 0.4 * inst
|
||||
prev_bytes, prev_t = bytes_now, now
|
||||
done = sum(1 for s in states if "Completed" in s)
|
||||
on_progress(min(1.0, done / max(total, 1)))
|
||||
on_progress(min(1.0, done / max(total, 1)), _eta_seconds(total_bytes, bytes_now, speed))
|
||||
if done >= total:
|
||||
completed = True
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user