Compare commits
2 Commits
c49cee9a20
...
d2a86932e0
| Author | SHA1 | Date | |
|---|---|---|---|
| d2a86932e0 | |||
| 73f1ae6e2f |
@@ -14,6 +14,7 @@ export function JobRow({
|
||||
album,
|
||||
kind,
|
||||
label,
|
||||
marker,
|
||||
meta,
|
||||
note,
|
||||
bar,
|
||||
@@ -22,6 +23,7 @@ export function JobRow({
|
||||
album: string;
|
||||
kind: Kind;
|
||||
label: string;
|
||||
marker?: string;
|
||||
meta?: ReactNode;
|
||||
note?: ReactNode;
|
||||
bar?: ReactNode;
|
||||
@@ -31,6 +33,7 @@ export function JobRow({
|
||||
<div className="stripe" />
|
||||
<div>
|
||||
<h3 className="title">
|
||||
{marker ? <span className="nextup">{marker}</span> : null}
|
||||
{album} <span className="artist">· {artist}</span>
|
||||
</h3>
|
||||
{meta ? <div className="meta">{meta}</div> : null}
|
||||
|
||||
@@ -388,6 +388,13 @@ nav.contents .sep { flex: 1; }
|
||||
.tab .n { color: var(--rule-2); margin-left: 6px; }
|
||||
.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) ───────────── */
|
||||
.dept.has-actions { align-items: center; }
|
||||
.dept.has-actions .fill { transform: none; }
|
||||
|
||||
@@ -189,6 +189,14 @@ export function Queue() {
|
||||
const j = jobOf(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 downloading = buckets.active.length > 0;
|
||||
// Smart default: land on the tab that has something worth looking at.
|
||||
@@ -289,6 +297,7 @@ export function Queue() {
|
||||
album={r.album}
|
||||
kind={d.kind}
|
||||
label={d.label}
|
||||
marker={r.id === nextUpId ? "Next up" : undefined}
|
||||
meta={meta}
|
||||
note={note}
|
||||
bar={bar}
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user