From efdd59276157ba0abea433ca49b01ea793561917 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Tue, 14 Jul 2026 23:36:41 +0200 Subject: [PATCH] feat(discover): Last.fm album-popularity helper LastfmAlbumPopularity.top_albums(conn, name) ranks an artist's albums by Last.fm playcount (read-through ApiCache, 12h; errors -> []). registry build_album_popularity returns it only when lastfm.api_key is set. Co-Authored-By: Claude Opus 4.8 (1M context) --- worker/lyra_worker/registry.py | 8 +++ .../lyra_worker/similarity/_lastfm_albums.py | 53 +++++++++++++++++++ worker/tests/test_lastfm_albums.py | 29 ++++++++++ 3 files changed, 90 insertions(+) create mode 100644 worker/lyra_worker/similarity/_lastfm_albums.py create mode 100644 worker/tests/test_lastfm_albums.py diff --git a/worker/lyra_worker/registry.py b/worker/lyra_worker/registry.py index 6a1f3cc..aaa62f1 100644 --- a/worker/lyra_worker/registry.py +++ b/worker/lyra_worker/registry.py @@ -13,6 +13,7 @@ from lyra_worker.browser import MbBrowser from lyra_worker.probe import AudioProbe from lyra_worker.resolver import MbResolver from lyra_worker.similarity._lastfm import LastfmSource +from lyra_worker.similarity._lastfm_albums import LastfmAlbumPopularity from lyra_worker.similarity._listenbrainz import ListenBrainzSource from lyra_worker.similarity.base import SimilaritySource from lyra_worker.tagger import Tagger @@ -67,3 +68,10 @@ def build_similarity_sources(config: dict, dsn: str | None = None) -> list[Simil if key: sources.append(LastfmSource(api_key=key, browser=build_browser(dsn))) return sources + + +def build_album_popularity(config: dict, dsn: str | None = None): + """Last.fm album-popularity provider for discovery, or None when no Last.fm key is set + (discovery then surfaces newest albums only).""" + key = config.get("lastfm.api_key") + return LastfmAlbumPopularity(api_key=key) if key else None diff --git a/worker/lyra_worker/similarity/_lastfm_albums.py b/worker/lyra_worker/similarity/_lastfm_albums.py new file mode 100644 index 0000000..af1515e --- /dev/null +++ b/worker/lyra_worker/similarity/_lastfm_albums.py @@ -0,0 +1,53 @@ +import requests + +from lyra_worker.apicache import cached_api + +_BASE = "https://ws.audioscrobbler.com/2.0/" + + +def _parse_top_albums(data) -> list[list]: + """Parse a Last.fm artist.gettopalbums response into [[name, mbid|None, playcount], …] + ordered by playcount desc. A single-album body arrives as a dict; an error body → [].""" + if isinstance(data.get("error"), (int, float)): + return [] + rows = data.get("topalbums", {}).get("album", []) + if isinstance(rows, dict): + rows = [rows] + out: list[list] = [] + for r in rows: + name = r.get("name") or "" + if not name: + continue + out.append([name, r.get("mbid") or None, int(r.get("playcount") or 0)]) + out.sort(key=lambda a: a[2], reverse=True) + return out + + +class LastfmAlbumPopularity: + """Ranks an artist's albums by Last.fm playcount, so discovery can surface each similar + artist's most-played album (alongside their newest). Read-through cached in ApiCache; any + failure yields [] so the sweep just falls back to newest-only.""" + + def __init__(self, api_key, base_url=_BASE, timeout: float = 15.0, limit: int = 25): + self._key = api_key + self._base = base_url + self._timeout = timeout + self._limit = limit + + def _fetch(self, artist_name: str) -> list[list]: + try: + res = requests.get(self._base, params={ + "method": "artist.gettopalbums", "artist": artist_name, + "api_key": self._key, "format": "json", "limit": str(self._limit), + "autocorrect": "1", + }, timeout=self._timeout) + return _parse_top_albums(res.json()) + except Exception: # a Last.fm outage must never abort the sweep + return [] + + def top_albums(self, conn, artist_name: str) -> list[list]: + norm = (artist_name or "").strip().lower() + if not norm: + return [] + return cached_api(conn, f"lfm-artist-top-albums:{norm}", 43200, + lambda: self._fetch(artist_name)) diff --git a/worker/tests/test_lastfm_albums.py b/worker/tests/test_lastfm_albums.py new file mode 100644 index 0000000..3fb9fe4 --- /dev/null +++ b/worker/tests/test_lastfm_albums.py @@ -0,0 +1,29 @@ +from lyra_worker.registry import build_album_popularity +from lyra_worker.similarity._lastfm_albums import _parse_top_albums + + +def test_parse_orders_by_playcount_and_keeps_mbids(): + data = {"topalbums": {"album": [ + {"name": "Older Hit", "mbid": "rg-old", "playcount": "5000"}, + {"name": "Newer", "mbid": "", "playcount": "9000"}, + {"name": "No Plays", "playcount": "0"}, + ]}} + assert _parse_top_albums(data) == [ + ["Newer", None, 9000], # highest playcount first; empty mbid -> None + ["Older Hit", "rg-old", 5000], + ["No Plays", None, 0], + ] + + +def test_parse_wraps_a_single_album_dict(): + data = {"topalbums": {"album": {"name": "Only One", "mbid": "rg1", "playcount": "3"}}} + assert _parse_top_albums(data) == [["Only One", "rg1", 3]] + + +def test_parse_returns_empty_on_error_body(): + assert _parse_top_albums({"error": 6, "message": "bad"}) == [] + + +def test_build_album_popularity_none_without_key(): + assert build_album_popularity({}) is None + assert build_album_popularity({"lastfm.api_key": "abc"}) is not None