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) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 23:36:41 +02:00
parent a09093f798
commit efdd592761
3 changed files with 90 additions and 0 deletions
+8
View File
@@ -13,6 +13,7 @@ from lyra_worker.browser import MbBrowser
from lyra_worker.probe import AudioProbe from lyra_worker.probe import AudioProbe
from lyra_worker.resolver import MbResolver from lyra_worker.resolver import MbResolver
from lyra_worker.similarity._lastfm import LastfmSource 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._listenbrainz import ListenBrainzSource
from lyra_worker.similarity.base import SimilaritySource from lyra_worker.similarity.base import SimilaritySource
from lyra_worker.tagger import Tagger from lyra_worker.tagger import Tagger
@@ -67,3 +68,10 @@ def build_similarity_sources(config: dict, dsn: str | None = None) -> list[Simil
if key: if key:
sources.append(LastfmSource(api_key=key, browser=build_browser(dsn))) sources.append(LastfmSource(api_key=key, browser=build_browser(dsn)))
return sources 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
@@ -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))
+29
View File
@@ -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