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