feat: add YouTubeAdapter with injectable client seam

This commit is contained in:
Jonathan
2026-07-10 21:19:27 +02:00
parent da1ed5caba
commit 5a3e64b057
2 changed files with 120 additions and 0 deletions
+55
View File
@@ -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),
)
+65
View File
@@ -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