diff --git a/worker/lyra_worker/adapters/_streamrip.py b/worker/lyra_worker/adapters/_streamrip.py new file mode 100644 index 0000000..501a073 --- /dev/null +++ b/worker/lyra_worker/adapters/_streamrip.py @@ -0,0 +1,82 @@ +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} diff --git a/worker/requirements.txt b/worker/requirements.txt index 3d62bf7..73e268a 100644 --- a/worker/requirements.txt +++ b/worker/requirements.txt @@ -2,3 +2,4 @@ psycopg[binary]>=3.2,<4 pytest>=8.3,<9 cryptography>=42,<46 yt-dlp>=2024.1 +streamrip>=2.0 diff --git a/worker/tests/test_qobuz_live.py b/worker/tests/test_qobuz_live.py new file mode 100644 index 0000000..4b8fffc --- /dev/null +++ b/worker/tests/test_qobuz_live.py @@ -0,0 +1,21 @@ +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)