feat: add SourceAdapter contract and fake adapters
This commit is contained in:
@@ -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."""
|
||||||
|
...
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user