feat: add SourceAdapter contract and fake adapters

This commit is contained in:
Jonathan
2026-07-10 18:35:52 +02:00
parent ffc4d1f483
commit 991365e746
4 changed files with 133 additions and 0 deletions
+23
View File
@@ -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."""
...
+67
View File
@@ -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")
+43
View File
@@ -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