From 0091c4f5e71019f3d2b4a588a7201afceadc8f47 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 10 Jul 2026 21:59:57 +0200 Subject: [PATCH] feat: add QobuzAdapter with injectable client seam --- worker/lyra_worker/adapters/qobuz.py | 65 ++++++++++++++++++++++++++ worker/tests/test_qobuz_adapter.py | 68 ++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 worker/lyra_worker/adapters/qobuz.py create mode 100644 worker/tests/test_qobuz_adapter.py diff --git a/worker/lyra_worker/adapters/qobuz.py b/worker/lyra_worker/adapters/qobuz.py new file mode 100644 index 0000000..9e9d52f --- /dev/null +++ b/worker/lyra_worker/adapters/qobuz.py @@ -0,0 +1,65 @@ +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), + ) diff --git a/worker/tests/test_qobuz_adapter.py b/worker/tests/test_qobuz_adapter.py new file mode 100644 index 0000000..9b8a3e4 --- /dev/null +++ b/worker/tests/test_qobuz_adapter.py @@ -0,0 +1,68 @@ +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