diff --git a/worker/lyra_worker/similarity/_listenbrainz.py b/worker/lyra_worker/similarity/_listenbrainz.py new file mode 100644 index 0000000..89a93b4 --- /dev/null +++ b/worker/lyra_worker/similarity/_listenbrainz.py @@ -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 diff --git a/worker/tests/test_listenbrainz.py b/worker/tests/test_listenbrainz.py new file mode 100644 index 0000000..8b0eb1f --- /dev/null +++ b/worker/tests/test_listenbrainz.py @@ -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