feat(discovery): ListenBrainz similarity source
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import requests
|
||||
|
||||
from lyra_worker.similarity.base import SimilarArtist
|
||||
|
||||
_DEFAULT_URL = "https://labs.api.listenbrainz.org"
|
||||
# ListenBrainz Labs similar-artists dataset requires an algorithm parameter.
|
||||
# NOTE: confirm this string + response field names against the live API during
|
||||
# manual verification; parsing below is defensive if the shape shifts.
|
||||
_DEFAULT_ALGORITHM = (
|
||||
"session_based_days_7500_session_300_contribution_5_threshold_15_limit_50_filter_True_skip_30"
|
||||
)
|
||||
|
||||
|
||||
class ListenBrainzSource:
|
||||
"""SimilaritySource backed by the (unauthenticated) ListenBrainz Labs API."""
|
||||
|
||||
name = "listenbrainz"
|
||||
|
||||
def __init__(self, base_url: str = _DEFAULT_URL, algorithm: str = _DEFAULT_ALGORITHM,
|
||||
timeout: float = 15.0):
|
||||
self._base = (base_url or _DEFAULT_URL).rstrip("/")
|
||||
self._algorithm = algorithm
|
||||
self._timeout = timeout
|
||||
|
||||
def health(self) -> bool:
|
||||
try:
|
||||
res = requests.get(self._base + "/", timeout=self._timeout)
|
||||
return res.status_code < 500
|
||||
except requests.RequestException:
|
||||
return False
|
||||
|
||||
def similar_artists(self, mbid: str) -> list[SimilarArtist]:
|
||||
res = requests.get(
|
||||
self._base + "/similar-artists/json",
|
||||
params={"artist_mbids": mbid, "algorithm": self._algorithm},
|
||||
timeout=self._timeout,
|
||||
)
|
||||
res.raise_for_status()
|
||||
out: list[SimilarArtist] = []
|
||||
for row in res.json() or []:
|
||||
ambid = row.get("artist_mbid")
|
||||
if not ambid or ambid == mbid:
|
||||
continue
|
||||
out.append(SimilarArtist(
|
||||
mbid=ambid,
|
||||
name=row.get("name") or row.get("comment") or "",
|
||||
score=float(row.get("score") or 0),
|
||||
))
|
||||
return out
|
||||
@@ -0,0 +1,43 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from lyra_worker.similarity._listenbrainz import ListenBrainzSource
|
||||
from lyra_worker.similarity.base import SimilarArtist
|
||||
|
||||
|
||||
def _resp(payload, status=200):
|
||||
r = MagicMock()
|
||||
r.status_code = status
|
||||
r.json.return_value = payload
|
||||
r.raise_for_status.side_effect = (
|
||||
None if status < 400 else requests.HTTPError(f"{status}"))
|
||||
return r
|
||||
|
||||
|
||||
def test_similar_artists_parses_and_drops_self():
|
||||
payload = [
|
||||
{"artist_mbid": "seed", "name": "Seed", "score": 100},
|
||||
{"artist_mbid": "c1", "name": "Cand One", "score": 42},
|
||||
{"artist_mbid": "c2", "name": "Cand Two", "score": 7},
|
||||
]
|
||||
with patch("lyra_worker.similarity._listenbrainz.requests.get", return_value=_resp(payload)):
|
||||
out = ListenBrainzSource().similar_artists("seed")
|
||||
assert out == [SimilarArtist("c1", "Cand One", 42.0), SimilarArtist("c2", "Cand Two", 7.0)]
|
||||
|
||||
|
||||
def test_similar_artists_raises_on_http_error():
|
||||
with patch("lyra_worker.similarity._listenbrainz.requests.get", return_value=_resp([], status=500)):
|
||||
try:
|
||||
ListenBrainzSource().similar_artists("seed")
|
||||
assert False, "expected HTTPError"
|
||||
except requests.HTTPError:
|
||||
pass
|
||||
|
||||
|
||||
def test_health_true_on_2xx_false_on_exception():
|
||||
with patch("lyra_worker.similarity._listenbrainz.requests.get", return_value=_resp([], status=200)):
|
||||
assert ListenBrainzSource().health() is True
|
||||
with patch("lyra_worker.similarity._listenbrainz.requests.get",
|
||||
side_effect=requests.ConnectionError("down")):
|
||||
assert ListenBrainzSource().health() is False
|
||||
Reference in New Issue
Block a user