Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
25 KiB
Lyra Qobuz Adapter Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add the Qobuz source adapter (lossless/hi-res FLAC via streamrip), implementing the existing SourceAdapter contract, and make the registry config-aware so Qobuz is included only when the user has entered credentials (via the settings page from Plan 3a). Adapter logic is unit-tested offline against a fake client; the real streamrip path is exercised only by an opt-in live test the user runs with their Qobuz account.
Architecture: QobuzAdapter depends on an injectable QobuzClient seam (like YtClient). Tests inject a FakeQobuzClient; the real StreamripClient (wraps streamrip's async QobuzClient, bridged to sync via asyncio.run) is constructed only in registry.build_adapters(config). The registry now takes the resolved config dict, builds each real adapter, and includes it only if adapter.health() is true — so an unconfigured source is skipped. The worker loop reads config once at startup and passes it in.
Tech Stack: Python 3.12 worker; streamrip (new dependency, async, v2 API). Tests: pytest with a fake client (offline) + an opt-in live test gated by LYRA_LIVE_TESTS and real Qobuz creds in env.
Global Constraints
- Python 3.12 in the worker container; version-flexible local deps.
- New dependency allowed:
streamrip(v2, async). No other new deps. - The
SourceAdapterprotocol,Candidate/Quality/DownloadResult/MBTargettypes, ranker, confidence, and pipeline are UNCHANGED. The adapter only implements the protocol. - Qobuz:
name="qobuz",tier=0. Quality is lossless FLAC:Quality(fmt="FLAC", lossless=True, bit_depth=<from Qobuz>, sample_rate=<Hz>). Qobuz reportsmaximum_sampling_ratein kHz (e.g. 44.1, 96) — convert to Hz (int(khz*1000)). registry.build_adapters(config: dict) -> list[SourceAdapter]— NEW signature takes the resolved config; returns only adapters whosehealth()is true. Qobuzhealth()= credentials present; YouTubehealth()= always true.- The real
StreamripClientis NOT unit-tested offline; it importsstreamripLAZILY (inside methods) so importing the module and constructing the client stay offline/cheap. Its network path is covered only by the opt-in live test (LYRA_LIVE_TESTS+QOBUZ_EMAIL/QOBUZ_PASSWORDenv). Normal runs never hit the network. - Live-validation note (carry into the live test): streamrip's embedding API is version-sensitive. The
StreamripClienthere follows streamrip v2 docs (Config.defaults(),config.session.qobuz.*,QobuzClient.search/login,PendingAlbum.resolve()/album.rip()); exact attribute paths (downloads.folder,album.tracks) and password handling may need adjustment against the installed streamrip version — that is the expected purpose of the live test. - Config is read ONCE at worker startup; changing credentials requires a worker restart to take effect (document; this matches current UX).
- Every task ends with a commit.
Shared interfaces (defined by tasks below)
# worker/lyra_worker/adapters/qobuz.py (Task 1)
class QobuzClient(Protocol):
def is_configured(self) -> bool: ...
def search_album(self, artist: str, album: str) -> list[dict]: ... # {source_ref, title, artist, track_count, bit_depth, sampling_rate}
def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict: ... # {track_count, path}; raises on failure
class QobuzAdapter: # implements SourceAdapter
name = "qobuz"; tier = 0
def __init__(self, client: QobuzClient): ...
def health(self) -> bool: ... # -> client.is_configured()
def search(self, target) -> list[Candidate]: ...
def download(self, candidate, dest, on_progress) -> DownloadResult: ...
# worker/lyra_worker/adapters/_streamrip.py (Task 2)
class StreamripClient: # real QobuzClient backed by streamrip (async bridged to sync)
def __init__(self, config: dict): ...
def is_configured(self) -> bool: ...
def search_album(self, artist, album) -> list[dict]: ...
def download(self, source_ref, dest, on_progress) -> dict: ...
# worker/lyra_worker/registry.py (Task 3, changed signature)
def build_adapters(config: dict) -> list[SourceAdapter]: ...
Task 1: QobuzAdapter + client seam (offline, fake client)
Files:
- Create:
worker/lyra_worker/adapters/qobuz.py - Test:
worker/tests/test_qobuz_adapter.py
Interfaces:
-
Consumes:
MBTarget,Candidate,Quality,DownloadResult;SourceAdapter. -
Produces:
QobuzClientProtocol (withis_configured) andQobuzAdapter.searchmaps each result dict toCandidate(source="qobuz", source_ref, matched_artist=r["artist"], matched_album=r["title"], quality=FLAC with bit_depth + sample_rate=int(r["sampling_rate"]*1000), track_count, source_tier=0).health()delegates toclient.is_configured().downloadwrapsclient.download(exception →ok=False). -
Step 1: Write the failing test
worker/tests/test_qobuz_adapter.py:
from lyra_worker.adapters.qobuz import QobuzAdapter
from lyra_worker.quality import quality_class
from lyra_worker.types import MBTarget
TARGET = MBTarget(artist="John Mayer", album="Continuum", track_count=12)
class FakeQobuzClient:
def __init__(self, configured=True, results=None, fail=False):
self._configured = configured
self._results = results if results is not None else [
{"source_ref": "qz123", "title": "Continuum", "artist": "John Mayer",
"track_count": 12, "bit_depth": 24, "sampling_rate": 96.0}
]
self._fail = fail
def is_configured(self):
return self._configured
def search_album(self, artist, album):
return self._results
def download(self, source_ref, dest, on_progress):
if self._fail:
raise RuntimeError("qobuz boom")
on_progress(1.0)
return {"track_count": 12, "path": dest}
def test_name_and_tier():
a = QobuzAdapter(FakeQobuzClient())
assert a.name == "qobuz"
assert a.tier == 0
def test_health_reflects_configuration():
assert QobuzAdapter(FakeQobuzClient(configured=True)).health() is True
assert QobuzAdapter(FakeQobuzClient(configured=False)).health() is False
def test_search_maps_to_lossless_hires_candidate():
cands = QobuzAdapter(FakeQobuzClient()).search(TARGET)
assert len(cands) == 1
c = cands[0]
assert c.source == "qobuz"
assert c.source_ref == "qz123"
assert c.matched_artist == "John Mayer"
assert c.matched_album == "Continuum"
assert c.track_count == 12
assert c.source_tier == 0
assert c.quality.lossless is True
assert c.quality.fmt == "FLAC"
assert c.quality.sample_rate == 96000 # 96.0 kHz -> Hz
assert c.quality.bit_depth == 24
assert quality_class(c.quality) == 3 # hi-res
def test_search_empty_when_no_results():
assert QobuzAdapter(FakeQobuzClient(results=[])).search(TARGET) == []
def test_download_success_and_failure():
ok = QobuzAdapter(FakeQobuzClient()).download(
QobuzAdapter(FakeQobuzClient()).search(TARGET)[0], "/tmp/x", lambda _p: None)
assert ok.ok is True and ok.track_count == 12
bad = QobuzAdapter(FakeQobuzClient(fail=True)).download(
QobuzAdapter(FakeQobuzClient()).search(TARGET)[0], "/tmp/x", lambda _p: None)
assert bad.ok is False and bad.error
- Step 2: Run the test to verify it fails
Run:
cd worker
worker/.venv/bin/python -m pytest tests/test_qobuz_adapter.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'lyra_worker.adapters.qobuz'.
- Step 3: Implement the adapter
worker/lyra_worker/adapters/qobuz.py:
from typing import Callable, Protocol
from lyra_worker.types import Candidate, DownloadResult, MBTarget, Quality
class QobuzClient(Protocol):
def is_configured(self) -> bool:
...
def search_album(self, artist: str, album: str) -> list[dict]:
"""Album matches: dicts with source_ref, title, artist, track_count, bit_depth, sampling_rate(kHz)."""
...
def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict:
"""Download into dest; return {track_count, path}. Raise on failure."""
...
def _quality(bit_depth: int, sampling_rate_khz: float) -> Quality:
return Quality(
fmt="FLAC",
lossless=True,
bit_depth=bit_depth,
sample_rate=int(sampling_rate_khz * 1000),
)
class QobuzAdapter:
name = "qobuz"
tier = 0
def __init__(self, client: QobuzClient):
self._client = client
def health(self) -> bool:
return self._client.is_configured()
def search(self, target: MBTarget) -> list[Candidate]:
candidates: list[Candidate] = []
for r in self._client.search_album(target.artist, target.album):
candidates.append(
Candidate(
source="qobuz",
source_ref=r["source_ref"],
matched_artist=r.get("artist", ""),
matched_album=r.get("title", ""),
quality=_quality(r.get("bit_depth", 16), r.get("sampling_rate", 44.1)),
track_count=r.get("track_count", 0) or 0,
source_tier=self.tier,
)
)
return candidates
def download(
self, candidate: Candidate, dest: str, on_progress: Callable[[float], None]
) -> DownloadResult:
try:
info = self._client.download(candidate.source_ref, dest, on_progress)
except Exception as e:
return DownloadResult(ok=False, error=str(e))
return DownloadResult(
ok=True,
path=info.get("path", dest),
track_count=info.get("track_count", candidate.track_count),
)
- Step 4: Run the test to verify it passes
Run:
cd worker
worker/.venv/bin/python -m pytest tests/test_qobuz_adapter.py -v
Expected: PASS — all six tests green.
- Step 5: Commit
git add worker/lyra_worker/adapters/qobuz.py worker/tests/test_qobuz_adapter.py
git commit -m "feat: add QobuzAdapter with injectable client seam"
Task 2: Real StreamripClient + streamrip dependency + opt-in live test
Files:
- Create:
worker/lyra_worker/adapters/_streamrip.py - Create:
worker/tests/test_qobuz_live.py - Modify:
worker/requirements.txt
Interfaces:
-
Consumes:
streamrip(lazily imported inside methods). -
Produces:
StreamripClient(config: dict)implementing theQobuzClientseam. Readsqobuz.email/qobuz.passwordfrom config;is_configured()= both present;search_album/downloadbridge streamrip's async API viaasyncio.run. -
Step 1: Add the dependency
Append to worker/requirements.txt:
streamrip>=2.0
Install into the venv: worker/.venv/bin/pip install -r worker/requirements.txt
- Step 2: Implement the real client
worker/lyra_worker/adapters/_streamrip.py:
import asyncio
import os
from typing import Callable
class StreamripClient:
"""Real Qobuz client backed by streamrip (v2, async → sync via asyncio.run).
NOT unit-tested offline; see test_qobuz_live.py. streamrip is imported lazily
inside the async helpers so importing/constructing this class stays offline.
"""
def __init__(self, config: dict):
self._email = config.get("qobuz.email", "")
self._password = config.get("qobuz.password", "")
def is_configured(self) -> bool:
return bool(self._email and self._password)
def _make_config(self, download_folder: str | None = None):
from streamrip.config import Config
cfg = Config.defaults()
cfg.session.qobuz.use_auth_token = False
cfg.session.qobuz.email_or_userid = self._email
cfg.session.qobuz.password_or_token = self._password
cfg.session.qobuz.quality = 3
if download_folder is not None:
cfg.session.downloads.folder = download_folder
return cfg
def search_album(self, artist: str, album: str) -> list[dict]:
return asyncio.run(self._search(f"{artist} {album}"))
async def _search(self, query: str) -> list[dict]:
from streamrip.client import QobuzClient
client = QobuzClient(self._make_config())
await client.login()
try:
pages = await client.search("album", query, limit=5)
finally:
await client.session.close()
results: list[dict] = []
for page in pages:
for item in page.get("albums", {}).get("items", []):
results.append(
{
"source_ref": str(item.get("id", "")),
"title": item.get("title", ""),
"artist": (item.get("artist") or {}).get("name", ""),
"track_count": item.get("tracks_count", 0) or 0,
"bit_depth": item.get("maximum_bit_depth", 16) or 16,
"sampling_rate": item.get("maximum_sampling_rate", 44.1) or 44.1,
}
)
return results
def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict:
return asyncio.run(self._download(source_ref, dest, on_progress))
async def _download(self, album_id: str, dest: str, on_progress: Callable[[float], None]) -> dict:
os.makedirs(dest, exist_ok=True)
from streamrip.client import QobuzClient
from streamrip.db import Database, Dummy
from streamrip.media import PendingAlbum
client = QobuzClient(self._make_config(download_folder=dest))
await client.login()
try:
on_progress(0.0)
db = Database(downloads=Dummy(), failed=Dummy())
pending = PendingAlbum(album_id, client, self._make_config(download_folder=dest), db)
album = await pending.resolve()
if album is None:
raise RuntimeError(f"could not resolve Qobuz album {album_id}")
await album.rip()
track_count = len(getattr(album, "tracks", []) or [])
on_progress(1.0)
finally:
await client.session.close()
return {"track_count": track_count, "path": dest}
- Step 3: Write the opt-in live test
worker/tests/test_qobuz_live.py:
import os
import pytest
from lyra_worker.adapters._streamrip import StreamripClient
pytestmark = pytest.mark.skipif(
not (os.environ.get("LYRA_LIVE_TESTS") and os.environ.get("QOBUZ_EMAIL") and os.environ.get("QOBUZ_PASSWORD")),
reason="live Qobuz test; set LYRA_LIVE_TESTS=1 + QOBUZ_EMAIL + QOBUZ_PASSWORD to run",
)
def test_live_search_returns_results():
client = StreamripClient(
{"qobuz.email": os.environ["QOBUZ_EMAIL"], "qobuz.password": os.environ["QOBUZ_PASSWORD"]}
)
results = client.search_album("John Mayer", "Continuum")
assert len(results) >= 1
assert all(r["source_ref"] for r in results)
# sanity: at least one result is a plausible lossless match
assert any(r["bit_depth"] >= 16 for r in results)
- Step 4: Verify (offline) the module imports and the live test is skipped
Run:
cd worker
worker/.venv/bin/python -c "from lyra_worker.adapters._streamrip import StreamripClient; print('import ok', StreamripClient({}).is_configured())"
worker/.venv/bin/python -m pytest tests/test_qobuz_live.py -v
Expected: prints import ok False (empty config → not configured, and importing the module does NOT import streamrip); the live test is reported skipped. Do NOT set the live env vars here.
- Step 5: Commit
git add worker/lyra_worker/adapters/_streamrip.py worker/tests/test_qobuz_live.py worker/requirements.txt
git commit -m "feat: add real streamrip Qobuz client and opt-in live test"
Task 3: Config-aware registry + worker wiring
Files:
- Modify:
worker/lyra_worker/registry.py - Modify:
worker/lyra_worker/main.py - Modify:
worker/tests/test_registry.py
Interfaces:
-
Consumes:
QobuzAdapter+StreamripClient(Tasks 1-2),YouTubeAdapter+YtDlpClient(Plan 3b),get_config(Plan 3a). -
Produces:
build_adapters(config: dict) -> list[SourceAdapter]returning[QobuzAdapter(StreamripClient(config)), YouTubeAdapter(YtDlpClient())]filtered to those whosehealth()is true.main.run_forever()reads config once viaget_config(conn)and passes it in. -
Step 1: Update the registry test (new expectations first)
Replace worker/tests/test_registry.py with:
from lyra_worker.adapters.qobuz import QobuzAdapter
from lyra_worker.adapters.youtube import YouTubeAdapter
from lyra_worker.registry import build_adapters
QOBUZ_CFG = {"qobuz.email": "me@example.com", "qobuz.password": "secret"}
def test_youtube_always_present_qobuz_skipped_without_config():
adapters = build_adapters({})
names = [a.name for a in adapters]
assert "youtube" in names
assert "qobuz" not in names # no creds -> health() false -> excluded
def test_qobuz_included_when_configured():
adapters = build_adapters(QOBUZ_CFG)
names = [a.name for a in adapters]
assert "qobuz" in names
assert "youtube" in names
qz = next(a for a in adapters if a.name == "qobuz")
yt = next(a for a in adapters if a.name == "youtube")
assert isinstance(qz, QobuzAdapter)
assert isinstance(yt, YouTubeAdapter)
def test_adapter_names_unique_and_qobuz_first():
adapters = build_adapters(QOBUZ_CFG)
names = [a.name for a in adapters]
assert len(names) == len(set(names))
assert names[0] == "qobuz" # tier 0 listed first
- Step 2: Run the test to verify it fails
Run:
cd worker
worker/.venv/bin/python -m pytest tests/test_registry.py -v
Expected: FAIL — old build_adapters() takes no args (TypeError when called with a config dict).
- Step 3: Update the registry
Replace worker/lyra_worker/registry.py with:
from lyra_worker.adapters._streamrip import StreamripClient
from lyra_worker.adapters._ytdlp import YtDlpClient
from lyra_worker.adapters.base import SourceAdapter
from lyra_worker.adapters.qobuz import QobuzAdapter
from lyra_worker.adapters.youtube import YouTubeAdapter
def build_adapters(config: dict) -> list[SourceAdapter]:
"""Real source adapters, best-tier first, filtered to those that are configured.
An adapter is included only if its health() is true (e.g. Qobuz requires
credentials in config). Plan 3d adds Soulseek (slskd) the same way.
"""
candidates: list[SourceAdapter] = [
QobuzAdapter(StreamripClient(config)),
YouTubeAdapter(YtDlpClient()),
]
return [a for a in candidates if a.health()]
- Step 4: Wire config into the worker loop
In worker/lyra_worker/main.py, update the imports and run_forever() so config is read once and passed to build_adapters. The function becomes:
import time
from lyra_worker.claim import claim_next
from lyra_worker.config import get_config
from lyra_worker.db import wait_for_db
from lyra_worker.pipeline import run_pipeline
from lyra_worker.registry import build_adapters
IDLE_SLEEP = 2.0
def run_forever() -> None:
conn = wait_for_db()
adapters = build_adapters(get_config(conn))
print(f"worker: {len(adapters)} adapter(s) enabled: {[a.name for a in adapters]}", flush=True)
print("worker: waiting for jobs", flush=True)
try:
while True:
job_id = claim_next(conn)
if job_id is None:
time.sleep(IDLE_SLEEP)
continue
print(f"worker: claimed job {job_id}", flush=True)
run_pipeline(conn, job_id, adapters)
print(f"worker: finished job {job_id}", flush=True)
finally:
conn.close()
if __name__ == "__main__":
run_forever()
(Keep the if __name__ == "__main__" guard.)
- Step 5: Run the tests to verify they pass
Run:
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
worker/.venv/bin/python -m pytest tests/test_registry.py -v
Expected: PASS — all three tests green.
- Step 6: Commit
git add worker/lyra_worker/registry.py worker/lyra_worker/main.py worker/tests/test_registry.py
git commit -m "feat: config-aware registry gating Qobuz on credentials; wire config into worker"
Task 4: Pipeline integration with the real Qobuz adapter class
Files:
- Test:
worker/tests/test_qobuz_pipeline.py
Interfaces:
-
Consumes:
run_pipeline,QobuzAdapter(Task 1),FakeQobuzClient(Task 1's test),connfixture +insert_request. -
Produces: proof that the real
QobuzAdapterclass (driven by a fake client, offline) flows through the pipeline toimported, choosing aqobuzcandidate and writing aqobuzLibraryItem. -
Step 1: Write the test
worker/tests/test_qobuz_pipeline.py:
from lyra_worker.adapters.qobuz import QobuzAdapter
from lyra_worker.claim import claim_next
from lyra_worker.pipeline import run_pipeline
from tests.conftest import insert_request
from tests.test_qobuz_adapter import FakeQobuzClient
def test_qobuz_adapter_flows_through_pipeline(conn):
job_id = insert_request(conn, artist="John Mayer", album="Continuum")
claim_next(conn)
run_pipeline(conn, job_id, [QobuzAdapter(FakeQobuzClient())], dest_root="/tmp/lib")
with conn.cursor() as cur:
cur.execute('SELECT state, "currentStage" FROM "Job" WHERE id = %s', (job_id,))
assert cur.fetchone() == ("imported", "import")
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
assert cur.fetchone()[0] == "qobuz"
cur.execute('SELECT source, format FROM "LibraryItem" WHERE artist = %s', ("John Mayer",))
row = cur.fetchone()
assert row == ("qobuz", "FLAC")
def test_qobuz_and_youtube_ranked_qobuz_wins(conn):
from lyra_worker.adapters.youtube import YouTubeAdapter
from tests.test_youtube_adapter import FakeYtClient
# Both sources return an exact-title match; Qobuz (lossless, tier 0) must outrank YouTube (lossy).
yt_results = [{"source_ref": "yt1", "title": "Continuum", "artist": "John Mayer", "track_count": 12}]
job_id = insert_request(conn, artist="John Mayer", album="Continuum")
claim_next(conn)
run_pipeline(
conn, job_id,
[QobuzAdapter(FakeQobuzClient()), YouTubeAdapter(FakeYtClient(results=yt_results))],
dest_root="/tmp/lib",
)
with conn.cursor() as cur:
cur.execute('SELECT source FROM "Candidate" WHERE "jobId" = %s AND chosen = true', (job_id,))
assert cur.fetchone()[0] == "qobuz" # lossless beats lossy
- Step 2: Run the test
Run:
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
worker/.venv/bin/python -m pytest tests/test_qobuz_pipeline.py -v
Expected: PASS immediately — exercises already-implemented code (adapter from Task 1 + existing pipeline/ranker). If it FAILS, investigate a real integration mismatch and report it rather than altering assertions. (Note: FakeQobuzClient echoes the target, so confidence clears the gate; Qobuz outranks YouTube via quality_class 2/3 > 1 and tier 0.)
- Step 3: Run the full worker suite
Run:
cd worker
export DATABASE_URL=postgresql://lyra:lyra@localhost:5432/lyra
worker/.venv/bin/python -m pytest -v
Expected: PASS — every suite green (qobuz adapter/pipeline, youtube, registry, config, crypto, pipeline, ranker, etc.), both live tests skipped, output pristine.
- Step 4: Commit
git add worker/tests/test_qobuz_pipeline.py
git commit -m "test: verify real QobuzAdapter flows through the pipeline and outranks YouTube"
Notes for the next plans
registry.build_adapters(config) is now config-aware and returns Qobuz (when configured) + YouTube. Plan 3d adds SoulseekAdapter (slskd REST client + an slskd service in docker-compose), following the same injectable-client + fake + opt-in-live pattern, gated on slskd.url/slskd.api_key in config via health(). Plan 4 then adds real MusicBrainz resolution in intake (filling MBTarget.track_count/total_duration_s — which makes the confidence gate and the tag-stage completeness check meaningful for all sources) plus real tagging/file-move in tag/import, and should also add the persistent /music volume mount so downloads survive container recreation. The live validation of StreamripClient (Task 2) against a real Qobuz account is a user step: run LYRA_LIVE_TESTS=1 QOBUZ_EMAIL=… QOBUZ_PASSWORD=… pytest tests/test_qobuz_live.py, and adjust the streamrip Config/attribute paths if the installed version differs.