From 991365e746336fe0144a9a17a1aad604919f9cee Mon Sep 17 00:00:00 2001 From: Jonathan Date: Fri, 10 Jul 2026 18:35:52 +0200 Subject: [PATCH] feat: add SourceAdapter contract and fake adapters --- worker/lyra_worker/adapters/__init__.py | 0 worker/lyra_worker/adapters/base.py | 23 +++++++++ worker/lyra_worker/adapters/fakes.py | 67 +++++++++++++++++++++++++ worker/tests/test_adapters.py | 43 ++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 worker/lyra_worker/adapters/__init__.py create mode 100644 worker/lyra_worker/adapters/base.py create mode 100644 worker/lyra_worker/adapters/fakes.py create mode 100644 worker/tests/test_adapters.py diff --git a/worker/lyra_worker/adapters/__init__.py b/worker/lyra_worker/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worker/lyra_worker/adapters/base.py b/worker/lyra_worker/adapters/base.py new file mode 100644 index 0000000..f6b9a2a --- /dev/null +++ b/worker/lyra_worker/adapters/base.py @@ -0,0 +1,23 @@ +from typing import Callable, Protocol, runtime_checkable + +from lyra_worker.types import Candidate, DownloadResult, MBTarget + + +@runtime_checkable +class SourceAdapter(Protocol): + name: str + tier: int + + def health(self) -> bool: + """Is this source configured and reachable?""" + ... + + def search(self, target: MBTarget) -> list[Candidate]: + """Return zero or more candidates matching target. No downloading.""" + ... + + def download( + self, candidate: Candidate, dest: str, on_progress: Callable[[float], None] + ) -> DownloadResult: + """Fetch candidate into dest, reporting progress 0.0..1.0.""" + ... diff --git a/worker/lyra_worker/adapters/fakes.py b/worker/lyra_worker/adapters/fakes.py new file mode 100644 index 0000000..2c2c7fe --- /dev/null +++ b/worker/lyra_worker/adapters/fakes.py @@ -0,0 +1,67 @@ +from typing import Callable + +from lyra_worker.types import Candidate, DownloadResult, MBTarget, Quality + +_QUALITIES = { + "qobuz": Quality(fmt="FLAC", lossless=True, bit_depth=24, sample_rate=96000), + "soulseek": Quality(fmt="FLAC", lossless=True, bit_depth=16, sample_rate=44100), + "youtube": Quality(fmt="OPUS", lossless=False, bitrate_kbps=160), +} + + +class _BaseFake: + name = "fake" + tier = 99 + + def __init__(self, matches: bool = True): + self._matches = matches + + def health(self) -> bool: + return True + + def search(self, target: MBTarget) -> list[Candidate]: + if not self._matches: + return [] + tracks = target.track_count or 10 + return [ + Candidate( + source=self.name, + source_ref=f"{self.name}:{target.artist}:{target.album}", + matched_artist=target.artist, + matched_album=target.album, + quality=_QUALITIES.get(self.name, _QUALITIES["youtube"]), + track_count=tracks, + ) + ] + + def download( + self, candidate: Candidate, dest: str, on_progress: Callable[[float], None] + ) -> DownloadResult: + for pct in (0.25, 0.5, 0.75, 1.0): + on_progress(pct) + return DownloadResult(ok=True, path=dest, track_count=candidate.track_count) + + +class FakeQobuz(_BaseFake): + name = "qobuz" + tier = 0 + + +class FakeSoulseek(_BaseFake): + name = "soulseek" + tier = 1 + + +class FakeYouTube(_BaseFake): + name = "youtube" + tier = 2 + + +class FailingAdapter(_BaseFake): + name = "qobuz" # pretends to be a real source that then fails to download + tier = 0 + + def download( + self, candidate: Candidate, dest: str, on_progress: Callable[[float], None] + ) -> DownloadResult: + return DownloadResult(ok=False, error="simulated download failure") diff --git a/worker/tests/test_adapters.py b/worker/tests/test_adapters.py new file mode 100644 index 0000000..b5512dd --- /dev/null +++ b/worker/tests/test_adapters.py @@ -0,0 +1,43 @@ +from lyra_worker.adapters.fakes import FakeQobuz, FakeSoulseek, FakeYouTube, FailingAdapter +from lyra_worker.types import MBTarget + +TARGET = MBTarget(artist="Radiohead", album="In Rainbows", track_count=10) +ALL_FAKES = [FakeQobuz(), FakeSoulseek(), FakeYouTube(), FailingAdapter()] + + +def _noop(_pct: float) -> None: + pass + + +def test_adapters_expose_name_and_tier(): + assert FakeQobuz().name == "qobuz" and FakeQobuz().tier == 0 + assert FakeSoulseek().name == "soulseek" and FakeSoulseek().tier == 1 + assert FakeYouTube().name == "youtube" and FakeYouTube().tier == 2 + + +def test_search_returns_wellformed_candidates(): + for adapter in ALL_FAKES: + for c in adapter.search(TARGET): + assert c.source == adapter.name + assert c.matched_artist and c.matched_album + assert c.track_count > 0 + assert c.source_ref + + +def test_matches_false_yields_no_candidates(): + assert FakeQobuz(matches=False).search(TARGET) == [] + + +def test_successful_download_reports_progress_and_ok(): + seen = [] + result = FakeQobuz().download(FakeQobuz().search(TARGET)[0], "/tmp/x", seen.append) + assert result.ok is True + assert result.track_count > 0 + assert seen and seen[-1] == 1.0 # progress reaches 100% + + +def test_failing_adapter_download_fails(): + fa = FailingAdapter() + result = fa.download(fa.search(TARGET)[0], "/tmp/x", _noop) + assert result.ok is False + assert result.error