From 5a3e64b0571319a620bf00f0312e961adbcd2055 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 10 Jul 2026 21:19:27 +0200 Subject: [PATCH] feat: add YouTubeAdapter with injectable client seam --- worker/lyra_worker/adapters/youtube.py | 55 ++++++++++++++++++++++ worker/tests/test_youtube_adapter.py | 65 ++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 worker/lyra_worker/adapters/youtube.py create mode 100644 worker/tests/test_youtube_adapter.py diff --git a/worker/lyra_worker/adapters/youtube.py b/worker/lyra_worker/adapters/youtube.py new file mode 100644 index 0000000..1cbaf34 --- /dev/null +++ b/worker/lyra_worker/adapters/youtube.py @@ -0,0 +1,55 @@ +from typing import Callable, Protocol + +from lyra_worker.types import Candidate, DownloadResult, MBTarget, Quality + +_YT_QUALITY = Quality(fmt="OPUS", lossless=False, bitrate_kbps=160) + + +class YtClient(Protocol): + def search_album(self, artist: str, album: str) -> list[dict]: + """Return album matches: dicts with keys source_ref, title, artist, track_count.""" + ... + + def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict: + """Download into dest; return {track_count, path}. Raise on failure.""" + ... + + +class YouTubeAdapter: + name = "youtube" + tier = 2 + + def __init__(self, client: YtClient): + self._client = client + + def health(self) -> bool: + return True + + 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="youtube", + source_ref=r["source_ref"], + matched_artist=r.get("artist", ""), + matched_album=r.get("title", ""), + quality=_YT_QUALITY, + track_count=r.get("track_count", 1) or 1, + 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: # network/extraction failures -> fall-through in the pipeline + 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_youtube_adapter.py b/worker/tests/test_youtube_adapter.py new file mode 100644 index 0000000..dede8de --- /dev/null +++ b/worker/tests/test_youtube_adapter.py @@ -0,0 +1,65 @@ +from lyra_worker.adapters.youtube import YouTubeAdapter +from lyra_worker.types import Candidate, MBTarget, Quality + +TARGET = MBTarget(artist="Radiohead", album="In Rainbows", track_count=10) + + +class FakeYtClient: + def __init__(self, results=None, fail=False): + self._results = results if results is not None else [ + {"source_ref": "yt:playlist:abc", "title": "In Rainbows", "artist": "Radiohead", "track_count": 10} + ] + self._fail = fail + self.downloaded = [] + + def search_album(self, artist, album): + return self._results + + def download(self, source_ref, dest, on_progress): + if self._fail: + raise RuntimeError("network boom") + on_progress(1.0) + self.downloaded.append((source_ref, dest)) + return {"track_count": 10, "path": dest} + + +def test_name_and_tier(): + a = YouTubeAdapter(FakeYtClient()) + assert a.name == "youtube" + assert a.tier == 2 + assert a.health() is True + + +def test_search_maps_results_to_candidates(): + cands = YouTubeAdapter(FakeYtClient()).search(TARGET) + assert len(cands) == 1 + c = cands[0] + assert c.source == "youtube" + assert c.source_ref == "yt:playlist:abc" + assert c.matched_artist == "Radiohead" + assert c.matched_album == "In Rainbows" + assert c.track_count == 10 + assert c.source_tier == 2 + assert c.quality.lossless is False + + +def test_search_empty_when_no_results(): + assert YouTubeAdapter(FakeYtClient(results=[])).search(TARGET) == [] + + +def test_download_success(): + client = FakeYtClient() + cand = YouTubeAdapter(client).search(TARGET)[0] + seen = [] + result = YouTubeAdapter(client).download(cand, "/tmp/x", seen.append) + assert result.ok is True + assert result.track_count == 10 + assert seen and seen[-1] == 1.0 + + +def test_download_failure_returns_not_ok(): + client = FakeYtClient(fail=True) + cand = YouTubeAdapter(FakeYtClient()).search(TARGET)[0] + result = YouTubeAdapter(client).download(cand, "/tmp/x", lambda _p: None) + assert result.ok is False + assert result.error