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