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:
@@ -6,7 +6,7 @@ import json
|
||||
import pytest
|
||||
|
||||
import lyra_worker.adapters._slskd as slskd_mod
|
||||
from lyra_worker.adapters._slskd import SlskdClient, _parse_search_responses
|
||||
from lyra_worker.adapters._slskd import SlskdClient, _eta_seconds, _parse_search_responses
|
||||
|
||||
|
||||
class _Resp:
|
||||
@@ -63,7 +63,7 @@ def test_stalled_peer_is_abandoned_and_cancelled(tmp_path, monkeypatch):
|
||||
fake = slskd_mod.requests
|
||||
|
||||
with pytest.raises(TimeoutError):
|
||||
_client(tmp_path).download(_REF, str(tmp_path / "dest"), lambda _p: None)
|
||||
_client(tmp_path).download(_REF, str(tmp_path / "dest"), lambda *_a: None)
|
||||
assert any(u.endswith("/api/v0/transfers/downloads/peerA/t1") for u in fake.deleted)
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ def test_error_state_is_cancelled(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
|
||||
fake = slskd_mod.requests
|
||||
with pytest.raises(RuntimeError):
|
||||
_client(tmp_path).download(_REF, str(tmp_path / "dest"), lambda _p: None)
|
||||
_client(tmp_path).download(_REF, str(tmp_path / "dest"), lambda *_a: None)
|
||||
assert any(u.endswith("/peerA/t1") for u in fake.deleted)
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ def test_slow_but_progressing_peer_is_not_abandoned(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
|
||||
monkeypatch.setattr(slskd_mod, "_STALL_POLLS", 2) # would fire early if progress were ignored
|
||||
|
||||
out = _client(tmp_path).download(ref, str(tmp_path / "dest"), lambda _p: None)
|
||||
out = _client(tmp_path).download(ref, str(tmp_path / "dest"), lambda *_a: None)
|
||||
assert out["track_count"] == 1
|
||||
assert slskd_mod.requests.deleted == [] # completed cleanly → nothing cancelled
|
||||
|
||||
@@ -108,3 +108,39 @@ def test_parse_ranks_free_and_fast_peers_first():
|
||||
]
|
||||
peers = [json.loads(c["source_ref"])["username"] for c in _parse_search_responses(responses)]
|
||||
assert peers == ["fast", "slow", "queued"] # free+fast first; no-slot peer last despite speed
|
||||
|
||||
|
||||
def test_eta_seconds_from_measured_rate():
|
||||
assert _eta_seconds(1000, 250, 250.0) == 3 # 750 bytes left / 250 B/s
|
||||
assert _eta_seconds(1000, 0, 100.0) == 10
|
||||
|
||||
|
||||
def test_eta_seconds_unknown_cases():
|
||||
assert _eta_seconds(1000, 250, 0.0) is None # no speed sample yet
|
||||
assert _eta_seconds(0, 0, 100.0) is None # unknown total size
|
||||
assert _eta_seconds(1000, 1000, 100.0) is None # already complete
|
||||
|
||||
|
||||
def test_download_reports_shrinking_eta(tmp_path, monkeypatch):
|
||||
# A 900-byte file arriving 300 bytes per 3s poll → measured speed 100 B/s → ETA counts down
|
||||
# (6s, 3s) then None on completion. on_progress gets (fraction, eta_seconds).
|
||||
(tmp_path / "01.flac").write_bytes(b"x" * 900) # so _retrieve finds the finished file
|
||||
ref = json.dumps({"username": "peerA", "files": [{"filename": r"d\Album\01.flac", "size": 900}]})
|
||||
polls = [
|
||||
{"directories": [{"files": [{"filename": r"d\Album\01.flac", "size": 900,
|
||||
"state": "InProgress", "bytesTransferred": b, "id": "t1"}]}]}
|
||||
for b in (300, 600)
|
||||
] + [
|
||||
{"directories": [{"files": [{"filename": r"d\Album\01.flac", "size": 900,
|
||||
"state": "Completed, Succeeded", "bytesTransferred": 900, "id": "t1"}]}]}
|
||||
]
|
||||
monkeypatch.setattr(slskd_mod, "requests", _SeqRequests(polls))
|
||||
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
|
||||
clock = iter(float(i) * 3 for i in range(100)) # 0,3,6,… deterministic monotonic time
|
||||
monkeypatch.setattr(slskd_mod.time, "monotonic", lambda: next(clock))
|
||||
|
||||
calls: list = []
|
||||
_client(tmp_path).download(ref, str(tmp_path / "dest"), lambda *a: calls.append(a))
|
||||
|
||||
etas = [a[1] for a in calls if len(a) > 1]
|
||||
assert etas == [6, 3, None] # counts down at 100 B/s, then None once complete
|
||||
|
||||
Reference in New Issue
Block a user