Files
Lyra/worker/tests/test_pipeline.py
Jonathan 0c5e73eb17 fix(worker): parallelize per-source searches in the match phase
Soulseek's slskd search polls up to ~2 min; searching adapters sequentially
added that on top of Qobuz's instant search. Run the per-adapter searches
concurrently via a ThreadPoolExecutor so total match time is the slowest
single source, not their sum. Only Qobuz drives streamrip's shared asyncio
loop and there's one Qobuz adapter, so no two threads use that loop at once.
Results collected in adapter order (map preserves order) for deterministic
candidate ordering; per-adapter failures still swallowed (down source = no
candidates).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 02:37:08 +02:00

233 lines
10 KiB
Python

from lyra_worker.adapters.fakes import FakeQobuz, FakeSoulseek, FakeYouTube, FailingAdapter
from lyra_worker.claim import claim_next
from lyra_worker.pipeline import run_pipeline
from tests.conftest import insert_request
def _job_state(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT state, "currentStage" FROM "Job" WHERE id = %s', (job_id,))
return cur.fetchone()
def _request_status(conn, job_id):
with conn.cursor() as cur:
cur.execute(
'SELECT status FROM "Request" WHERE id = (SELECT "requestId" FROM "Job" WHERE id = %s)',
(job_id,),
)
return cur.fetchone()[0]
def _download_progress(conn, job_id):
with conn.cursor() as cur:
cur.execute('SELECT "downloadProgress" FROM "Job" WHERE id = %s', (job_id,))
return cur.fetchone()[0]
def test_success_picks_best_source_and_imports(conn):
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [FakeYouTube(), FakeSoulseek(), FakeQobuz()], dest_root="/tmp/lib")
assert _job_state(conn, job_id) == ("imported", "import")
assert _request_status(conn, job_id) == "completed"
assert _download_progress(conn, job_id) == 1.0 # a successful download completes at 100%
with conn.cursor() as cur:
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
assert cur.fetchone()[0] == "qobuz" # highest quality won
cur.execute('SELECT source, format FROM "LibraryItem" WHERE artist = %s', ("Radiohead",))
row = cur.fetchone()
assert row[0] == "qobuz"
def test_falls_through_when_best_download_fails(conn):
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
# FailingAdapter is source 'qobuz' (tier 0) whose download fails; Soulseek should win.
run_pipeline(conn, job_id, [FailingAdapter(), FakeSoulseek()], dest_root="/tmp/lib")
assert _job_state(conn, job_id) == ("imported", "import")
assert _download_progress(conn, job_id) == 1.0 # falls through, but still completes at 100%
with conn.cursor() as cur:
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
assert cur.fetchone()[0] == "soulseek"
def test_fallthrough_marks_exactly_one_chosen(conn):
# `chosen` now marks the source in flight at each attempt; after falling through from a
# failed source to a working one, exactly ONE candidate stays chosen (the winner) — the
# attempted-then-failed source must be un-marked (exclusive _mark_chosen).
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [FailingAdapter(), FakeSoulseek()], dest_root="/tmp/lib")
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
assert cur.fetchone()[0] == 1
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
assert cur.fetchone()[0] == "soulseek"
def test_no_match_goes_to_needs_attention(conn):
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [FakeQobuz(matches=False)], dest_root="/tmp/lib")
assert _job_state(conn, job_id)[0] == "needs_attention"
assert _request_status(conn, job_id) == "needs_attention"
def test_all_downloads_fail_goes_to_needs_attention(conn):
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [FailingAdapter()], dest_root="/tmp/lib")
assert _job_state(conn, job_id)[0] == "needs_attention"
assert _download_progress(conn, job_id) == 0.0 # never reached 1.0 — all attempts failed
def test_incomplete_download_goes_to_needs_attention(conn):
from lyra_worker.adapters.fakes import FakeQobuz
from lyra_worker.types import DownloadResult
class TruncatingQobuz(FakeQobuz):
def download(self, candidate, dest, on_progress):
on_progress(1.0)
# reports fewer tracks than the candidate promised -> integrity check fails
return DownloadResult(ok=True, path=dest, track_count=candidate.track_count - 1)
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [TruncatingQobuz()], dest_root="/tmp/lib")
assert _job_state(conn, job_id)[0] == "needs_attention"
assert _request_status(conn, job_id) == "needs_attention"
def test_dedupe_skips_already_in_library(conn):
# First acquisition
job1 = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job1, [FakeQobuz()], dest_root="/tmp/lib")
# Second request for the same album
job2 = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job2, [FakeQobuz()], dest_root="/tmp/lib")
assert _job_state(conn, job2) == ("imported", "import")
with conn.cursor() as cur:
# dedupe path creates no candidates for the second job
cur.execute('SELECT count(*) FROM "Candidate" WHERE "jobId" = %s', (job2,))
assert cur.fetchone()[0] == 0
cur.execute('SELECT count(*) FROM "LibraryItem" WHERE artist = %s AND album = %s',
("Radiohead", "In Rainbows"))
assert cur.fetchone()[0] == 1 # not duplicated
class _DurationResolver:
"""Resolver that stamps a known MB total duration on the target (the real MB path)."""
def __init__(self, total_s, tracks=10):
self._total, self._tracks = total_s, tracks
def resolve(self, artist, album):
from lyra_worker.types import MBTarget
return MBTarget(artist=artist, album=album, track_count=self._tracks, total_duration_s=self._total)
def test_rejects_a_download_far_shorter_than_the_mb_total(conn):
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
# MB says ~40 min; only ~5 min of audio staged (truncated/preview tracks) → reject.
run_pipeline(conn, job_id, [FakeQobuz()], resolver=_DurationResolver(2400),
measure_duration=lambda _s: 300.0, dest_root="/tmp/lib")
state, _stage = _job_state(conn, job_id)
assert state == "needs_attention"
with conn.cursor() as cur:
cur.execute('SELECT error FROM "Job" WHERE id = %s', (job_id,))
assert "too short" in cur.fetchone()[0]
cur.execute('SELECT count(*) FROM "LibraryItem" WHERE album = %s', ("In Rainbows",))
assert cur.fetchone()[0] == 0 # nothing imported
def test_accepts_a_download_within_the_duration_tolerance(conn):
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
# ~39 of ~40 min → within the 85% tolerance → imports normally.
run_pipeline(conn, job_id, [FakeQobuz()], resolver=_DurationResolver(2400),
measure_duration=lambda _s: 2340.0, dest_root="/tmp/lib")
assert _job_state(conn, job_id) == ("imported", "import")
def test_force_reacquires_an_album_already_in_library(conn):
# First acquisition puts the album in the library.
job1 = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job1, [FakeQobuz()], dest_root="/tmp/lib")
# A forced re-acquire skips the dedupe → it searches + persists candidates again.
job2 = insert_request(conn, artist="Radiohead", album="In Rainbows", force=True)
claim_next(conn)
run_pipeline(conn, job2, [FakeQobuz()], dest_root="/tmp/lib", upgrade_cutoff=2)
assert _job_state(conn, job2) == ("imported", "import")
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "Candidate" WHERE "jobId" = %s', (job2,))
assert cur.fetchone()[0] >= 1 # dedupe bypassed — it actually ran the search
cur.execute('SELECT count(*) FROM "LibraryItem" WHERE artist = %s AND album = %s',
("Radiohead", "In Rainbows"))
assert cur.fetchone()[0] == 1 # still not duplicated (keep-if-better import)
def test_below_threshold_candidates_are_persisted_for_diagnosis(conn):
from lyra_worker.adapters.fakes import FakeQobuz
from lyra_worker.types import MBTarget
class WrongAlbumQobuz(FakeQobuz):
def search(self, target):
# returns a candidate for a different album -> scores below the gate
return super().search(MBTarget(artist=target.artist, album="Some Completely Unrelated Title"))
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
run_pipeline(conn, job_id, [WrongAlbumQobuz()], dest_root="/tmp/lib")
assert _job_state(conn, job_id)[0] == "needs_attention"
with conn.cursor() as cur:
cur.execute('SELECT count(*) FROM "Candidate" WHERE "jobId" = %s', (job_id,))
assert cur.fetchone()[0] >= 1 # found-but-rejected candidate retained for diagnosis
def test_searches_run_concurrently_across_adapters(conn):
# Two sources that each take ~0.5s to search must run in parallel: total match time is ~the
# slowest single search (~0.5s), not the sum (~1.0s). Also verifies concurrency drops no
# candidates — both sources' contributions are persisted.
import time as _t
class _SlowQobuz(FakeQobuz):
def search(self, target):
_t.sleep(0.5)
return super().search(target)
class _SlowSoulseek(FakeSoulseek):
def search(self, target):
_t.sleep(0.5)
return super().search(target)
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
start = _t.monotonic()
run_pipeline(conn, job_id, [_SlowQobuz(), _SlowSoulseek()], dest_root="/tmp/lib")
elapsed = _t.monotonic() - start
assert elapsed < 0.9 # concurrent (~0.5s), not sequential (~1.0s)
with conn.cursor() as cur:
cur.execute('SELECT count(DISTINCT source) FROM "Candidate" WHERE "jobId" = %s', (job_id,))
assert cur.fetchone()[0] == 2 # both adapters contributed candidates
def test_duplicate_adapter_names_raise(conn):
import pytest
from lyra_worker.adapters.fakes import FakeQobuz
job_id = insert_request(conn, artist="Radiohead", album="In Rainbows")
claim_next(conn)
with pytest.raises(ValueError):
run_pipeline(conn, job_id, [FakeQobuz(), FakeQobuz()], dest_root="/tmp/lib")