Compare commits

...

2 Commits

Author SHA1 Message Date
Jonathan d2a86932e0 fix(worker): rank Soulseek peers by estimated transfer time, not raw speed
Peer selection ranked (free-slot, advertised-speed, short-queue), ignoring file
size. But Soulseek FLAC rips of one album vary ~2x in size (a 24-bit/bloated rip vs
a standard CD rip) and Lyra scores them the SAME quality class (the slskd search
exposes no bit-depth, so every FLAC is class 2). So a peer advertising high speed
but serving 40MB/track files would out-rank a smaller standard rip that actually
finishes sooner — then blow past the 60-min per-peer backstop, time out, fall
through, and restart at 0% (Linkin Park "Hybrid Theory" looping for hours).

Rank by estimated transfer time instead: album bytes ÷ advertised speed, after the
free-slot check. Prefers whichever peer FINISHES first — fast peers and/or smaller
standard-edition rips — with no quality loss (all class 2). Equal-size case still
reduces to speed order, so existing ranking behaviour is preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:17:44 +02:00
Jonathan 73f1ae6e2f feat(web): order the In-queue tab next-up-first with a "Next up" marker
The queue tab showed newest-first (API returns createdAt desc) but the worker claims
the oldest queued job next (claim_next: ORDER BY createdAt ASC) — so the item that ran
next was at the BOTTOM. Sort the queue bucket ascending so it matches processing
order, and badge the first claimable row (oldest, not individually paused) as "Next up".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 20:56:42 +02:00
5 changed files with 50 additions and 7 deletions
+3
View File
@@ -14,6 +14,7 @@ export function JobRow({
album, album,
kind, kind,
label, label,
marker,
meta, meta,
note, note,
bar, bar,
@@ -22,6 +23,7 @@ export function JobRow({
album: string; album: string;
kind: Kind; kind: Kind;
label: string; label: string;
marker?: string;
meta?: ReactNode; meta?: ReactNode;
note?: ReactNode; note?: ReactNode;
bar?: ReactNode; bar?: ReactNode;
@@ -31,6 +33,7 @@ export function JobRow({
<div className="stripe" /> <div className="stripe" />
<div> <div>
<h3 className="title"> <h3 className="title">
{marker ? <span className="nextup">{marker}</span> : null}
{album} <span className="artist">· {artist}</span> {album} <span className="artist">· {artist}</span>
</h3> </h3>
{meta ? <div className="meta">{meta}</div> : null} {meta ? <div className="meta">{meta}</div> : null}
+7
View File
@@ -388,6 +388,13 @@ nav.contents .sep { flex: 1; }
.tab .n { color: var(--rule-2); margin-left: 6px; } .tab .n { color: var(--rule-2); margin-left: 6px; }
.tab.has-errors .n { color: var(--alert); } .tab.has-errors .n { color: var(--alert); }
/* "Next up" badge on the first queued row (the item the worker claims next) */
.nextup {
display: inline-block; vertical-align: middle; margin-right: 9px; transform: translateY(-2px);
font-family: var(--mono); font-size: 0.56rem; letter-spacing: 0.13em; text-transform: uppercase;
color: var(--accent-ink); background: var(--accent); padding: 2px 7px; border-radius: 4px;
}
/* ── Kebab menu (⋮ popup for press actions) ───────────── */ /* ── Kebab menu (⋮ popup for press actions) ───────────── */
.dept.has-actions { align-items: center; } .dept.has-actions { align-items: center; }
.dept.has-actions .fill { transform: none; } .dept.has-actions .fill { transform: none; }
+9
View File
@@ -189,6 +189,14 @@ export function Queue() {
const j = jobOf(r); const j = jobOf(r);
buckets[categoryOf(j.state, j.currentStage)].push(r); buckets[categoryOf(j.state, j.currentStage)].push(r);
} }
// The API returns rows newest-first, but the worker claims the OLDEST queued job next
// (claim_next: ORDER BY createdAt ASC). Order the queue tab to match, so the item that runs
// next sits at the top instead of the bottom.
const ts = (r: Row) => (r.createdAt ? new Date(r.createdAt).getTime() : 0);
buckets.queue.sort((a, b) => ts(a) - ts(b));
// "Next up" = the first queued item the worker will actually claim (oldest, not individually
// paused) — mark it so the ordering reads clearly.
const nextUpId = buckets.queue.find((r) => !jobOf(r).paused)?.id ?? null;
const queuedCount = buckets.queue.length; const queuedCount = buckets.queue.length;
const downloading = buckets.active.length > 0; const downloading = buckets.active.length > 0;
// Smart default: land on the tab that has something worth looking at. // Smart default: land on the tab that has something worth looking at.
@@ -289,6 +297,7 @@ export function Queue() {
album={r.album} album={r.album}
kind={d.kind} kind={d.kind}
label={d.label} label={d.label}
marker={r.id === nextUpId ? "Next up" : undefined}
meta={meta} meta={meta}
note={note} note={note}
bar={bar} bar={bar}
+16 -7
View File
@@ -72,14 +72,18 @@ def _keep_matching(responses: list, needle: str) -> list:
def _parse_search_responses(responses: list) -> list[dict]: def _parse_search_responses(responses: list) -> list[dict]:
"""Turn slskd search responses into album candidates (one per peer+directory), ordered so the """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, peers that will FINISH first come first: a free upload slot, then the shortest estimated
then a shorter queue. The pipeline tries candidates in this order, so ranking fast/free peers transfer time (album bytes ÷ the peer's advertised speed), then a shorter queue. Ranking on
first (over slow or queued ones) is what makes the Soulseek fall-through actually converge. estimated time — not raw speed — matters because Soulseek FLAC rips of the same album vary
Pure — no I/O — so it is unit-tested offline.""" ~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] = [] scored: list[tuple] = []
for resp in responses: for resp in responses:
username = resp.get("username", "") 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) speed = int(resp.get("uploadSpeed") or 0)
queue = int(resp.get("queueLength") or 0) queue = int(resp.get("queueLength") or 0)
by_dir: dict[str, list] = {} by_dir: dict[str, list] = {}
@@ -94,6 +98,10 @@ def _parse_search_responses(responses: list) -> list[dict]:
for directory, dfiles in by_dir.items(): for directory, dfiles in by_dir.items():
if not dfiles: if not dfiles:
continue 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) lossless = all(f["ext"] in _LOSSLESS_EXT for f in dfiles)
dirname = _basename(directory) dirname = _basename(directory)
if " - " in dirname: if " - " in dirname:
@@ -111,8 +119,9 @@ def _parse_search_responses(responses: list) -> list[dict]:
"format": "FLAC" if lossless else "MP3", "format": "FLAC" if lossless else "MP3",
"bitrate": None, "bitrate": None,
} }
scored.append((has_slot, speed, -queue, candidate)) scored.append((has_slot, est_seconds, queue, candidate))
scored.sort(key=lambda t: (t[0], t[1], t[2]), reverse=True) # free + fast + short-queue first # 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] return [c for *_rest, c in scored]
+15
View File
@@ -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 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(): 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, 250, 250.0) == 3 # 750 bytes left / 250 B/s
assert _eta_seconds(1000, 0, 100.0) == 10 assert _eta_seconds(1000, 0, 100.0) == 10