diff --git a/worker/lyra_worker/adapters/_slskd.py b/worker/lyra_worker/adapters/_slskd.py index e0f3f1e..6b870fa 100644 --- a/worker/lyra_worker/adapters/_slskd.py +++ b/worker/lyra_worker/adapters/_slskd.py @@ -72,14 +72,18 @@ def _keep_matching(responses: list, needle: str) -> list: def _parse_search_responses(responses: list) -> list[dict]: """Turn slskd search responses into album candidates (one per peer+directory), ordered so the - peers most likely to deliver quickly come first: a free upload slot, then higher upload speed, - then a shorter queue. The pipeline tries candidates in this order, so ranking fast/free peers - first (over slow or queued ones) is what makes the Soulseek fall-through actually converge. - Pure — no I/O — so it is unit-tested offline.""" + peers that will FINISH first come first: a free upload slot, then the shortest estimated + transfer time (album bytes ÷ the peer's advertised speed), then a shorter queue. Ranking on + estimated time — not raw speed — matters because Soulseek FLAC rips of the same album vary + ~2x in size (a 24-bit/bloated rip vs a standard CD rip), and Lyra scores them the SAME quality + class (the search exposes no bit-depth), so a peer advertising high speed but serving huge + files can lose a race — and blow past the download backstop — against a peer with a smaller + standard rip. The pipeline tries candidates in this order, so this is what makes the Soulseek + fall-through converge on a copy that actually completes. Pure — no I/O — unit-tested offline.""" scored: list[tuple] = [] for resp in responses: username = resp.get("username", "") - has_slot = 1 if resp.get("hasFreeUploadSlot") else 0 + has_slot = 0 if resp.get("hasFreeUploadSlot") else 1 # 0 sorts first (free slot = starts now) speed = int(resp.get("uploadSpeed") or 0) queue = int(resp.get("queueLength") or 0) by_dir: dict[str, list] = {} @@ -94,6 +98,10 @@ def _parse_search_responses(responses: list) -> list[dict]: for directory, dfiles in by_dir.items(): if not dfiles: continue + # Estimated seconds to pull this album from this peer at its advertised rate. Unknown/ + # zero speed → a low nominal (1 B/s) so total size still orders those peers last. + total_bytes = sum(int(f["size"] or 0) for f in dfiles) + est_seconds = total_bytes / (speed if speed > 0 else 1) lossless = all(f["ext"] in _LOSSLESS_EXT for f in dfiles) dirname = _basename(directory) if " - " in dirname: @@ -111,8 +119,9 @@ def _parse_search_responses(responses: list) -> list[dict]: "format": "FLAC" if lossless else "MP3", "bitrate": None, } - scored.append((has_slot, speed, -queue, candidate)) - scored.sort(key=lambda t: (t[0], t[1], t[2]), reverse=True) # free + fast + short-queue first + scored.append((has_slot, est_seconds, queue, candidate)) + # ascending: free slot first, then shortest estimated transfer, then shortest queue + scored.sort(key=lambda t: (t[0], t[1], t[2])) return [c for *_rest, c in scored] diff --git a/worker/tests/test_slskd_cancel.py b/worker/tests/test_slskd_cancel.py index 967fa59..c30cc4a 100644 --- a/worker/tests/test_slskd_cancel.py +++ b/worker/tests/test_slskd_cancel.py @@ -110,6 +110,21 @@ def test_parse_ranks_free_and_fast_peers_first(): assert peers == ["fast", "slow", "queued"] # free+fast first; no-slot peer last despite speed +def test_parse_prefers_shorter_transfer_over_raw_speed(): + # The Hybrid Theory case: a peer advertising higher speed but serving a ~2x-larger rip (24-bit + # / bloated FLAC) should lose to a peer with lower speed but a smaller standard rip that + # finishes sooner — both are the same quality class to Lyra, so faster-to-finish wins. + responses = [ + {"username": "bloated", "hasFreeUploadSlot": True, "uploadSpeed": 20000, "queueLength": 0, + "files": [{"filename": rf"x\LP - Album\{i:02}.flac", "size": 40_000_000} for i in range(12)]}, + {"username": "lean", "hasFreeUploadSlot": True, "uploadSpeed": 15000, "queueLength": 0, + "files": [{"filename": rf"y\LP - Album\{i:02}.flac", "size": 24_000_000} for i in range(12)]}, + ] + # bloated est = 480MB/20000 = 24000s; lean est = 288MB/15000 = 19200s → lean finishes first + peers = [json.loads(c["source_ref"])["username"] for c in _parse_search_responses(responses)] + assert peers[0] == "lean" + + 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