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:
@@ -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))
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user