Scrobbling: - Last.fm: API signing, web auth flow, batch scrobble (50/request) - ListenBrainz: submit_listens (batch 1000, listen_type=import) - Worker hook: scrobbles unscrobbled events after each poll - DB: scrobbled_lastfm/scrobbled_listenbrainz tracking columns - Settings: API secret input, authorize button, scrobble toggles - Config: lastfm.api_secret, lastfm.session_key, *.scrobble_enabled What's New: added all features from this session (scrobbling, personalized discovery, stats page, SoulID, lossy codecs, import, hero redesign, Hydrabase, orphan fixes, year collection).
490 lines
16 KiB
Python
490 lines
16 KiB
Python
import requests
|
|
import time
|
|
import threading
|
|
from typing import Dict, Optional, Any, List
|
|
from functools import wraps
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("lastfm_client")
|
|
|
|
# Global rate limiting variables
|
|
_last_api_call_time = 0
|
|
_api_call_lock = threading.Lock()
|
|
MIN_API_INTERVAL = 0.2 # 200ms between calls (Last.fm allows 5 req/sec)
|
|
|
|
|
|
def rate_limited(func):
|
|
"""Decorator to enforce rate limiting on Last.fm API calls"""
|
|
@wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
global _last_api_call_time
|
|
|
|
with _api_call_lock:
|
|
current_time = time.time()
|
|
time_since_last_call = current_time - _last_api_call_time
|
|
|
|
if time_since_last_call < MIN_API_INTERVAL:
|
|
sleep_time = MIN_API_INTERVAL - time_since_last_call
|
|
time.sleep(sleep_time)
|
|
|
|
_last_api_call_time = time.time()
|
|
|
|
try:
|
|
result = func(*args, **kwargs)
|
|
return result
|
|
except Exception as e:
|
|
if "rate limit" in str(e).lower() or "429" in str(e):
|
|
logger.warning(f"Last.fm rate limit hit, implementing backoff: {e}")
|
|
time.sleep(5.0)
|
|
raise e
|
|
return wrapper
|
|
|
|
|
|
class LastFMClient:
|
|
"""Client for interacting with the Last.fm API (metadata + scrobbling)"""
|
|
|
|
BASE_URL = "https://ws.audioscrobbler.com/2.0/"
|
|
|
|
def __init__(self, api_key: str = "", api_secret: str = "", session_key: str = ""):
|
|
self.api_key = api_key
|
|
self.api_secret = api_secret
|
|
self.session_key = session_key
|
|
self.session = requests.Session()
|
|
self.session.headers.update({
|
|
'User-Agent': 'SoulSync/1.0',
|
|
'Accept': 'application/json'
|
|
})
|
|
logger.info("Last.fm client initialized")
|
|
|
|
def _sign_request(self, params: dict) -> str:
|
|
"""Generate MD5 API signature for write operations."""
|
|
import hashlib
|
|
# Sort params alphabetically, concatenate key+value pairs, append secret
|
|
sorted_str = ''.join(f'{k}{v}' for k, v in sorted(params.items()))
|
|
return hashlib.md5((sorted_str + self.api_secret).encode('utf-8')).hexdigest()
|
|
|
|
def get_auth_url(self, callback_url: str) -> Optional[str]:
|
|
"""Generate the Last.fm authorization URL for scrobbling.
|
|
|
|
User visits this URL, authorizes SoulSync, gets redirected back with a token.
|
|
"""
|
|
if not self.api_key:
|
|
return None
|
|
return f"https://www.last.fm/api/auth/?api_key={self.api_key}&cb={callback_url}"
|
|
|
|
def get_session_key(self, token: str) -> Optional[str]:
|
|
"""Exchange an auth token for a session key (one-time after user authorizes).
|
|
|
|
Returns the session key string, or None on failure.
|
|
"""
|
|
if not self.api_key or not self.api_secret or not token:
|
|
return None
|
|
|
|
params = {
|
|
'method': 'auth.getSession',
|
|
'api_key': self.api_key,
|
|
'token': token,
|
|
}
|
|
params['api_sig'] = self._sign_request(params)
|
|
params['format'] = 'json'
|
|
|
|
try:
|
|
response = self.session.get(self.BASE_URL, params=params, timeout=10)
|
|
data = response.json()
|
|
session = data.get('session', {})
|
|
key = session.get('key')
|
|
if key:
|
|
self.session_key = key
|
|
logger.info(f"Last.fm session key obtained for user: {session.get('name', '?')}")
|
|
return key
|
|
else:
|
|
error = data.get('error', 'Unknown')
|
|
logger.error(f"Last.fm auth failed: {data.get('message', error)}")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Last.fm session key exchange failed: {e}")
|
|
return None
|
|
|
|
def can_scrobble(self) -> bool:
|
|
"""Check if scrobbling is possible (has api_key, api_secret, and session_key)."""
|
|
return bool(self.api_key and self.api_secret and self.session_key)
|
|
|
|
@rate_limited
|
|
def scrobble_tracks(self, tracks: List[Dict]) -> bool:
|
|
"""Scrobble up to 50 tracks at once via track.scrobble POST endpoint.
|
|
|
|
Args:
|
|
tracks: list of dicts with {artist, track, album, timestamp (unix int)}
|
|
|
|
Returns:
|
|
True if scrobble succeeded.
|
|
"""
|
|
if not self.can_scrobble():
|
|
return False
|
|
|
|
if not tracks:
|
|
return True
|
|
|
|
# Last.fm accepts max 50 scrobbles per request
|
|
batch = tracks[:50]
|
|
|
|
params = {
|
|
'method': 'track.scrobble',
|
|
'api_key': self.api_key,
|
|
'sk': self.session_key,
|
|
}
|
|
|
|
for i, t in enumerate(batch):
|
|
ts = t.get('timestamp')
|
|
if isinstance(ts, str):
|
|
try:
|
|
from datetime import datetime
|
|
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
|
|
ts = int(dt.timestamp())
|
|
except Exception:
|
|
continue
|
|
|
|
params[f'artist[{i}]'] = t.get('artist', '')
|
|
params[f'track[{i}]'] = t.get('track', '')
|
|
params[f'timestamp[{i}]'] = str(ts)
|
|
if t.get('album'):
|
|
params[f'album[{i}]'] = t['album']
|
|
|
|
params['api_sig'] = self._sign_request(params)
|
|
params['format'] = 'json'
|
|
|
|
try:
|
|
response = self.session.post(self.BASE_URL, data=params, timeout=15)
|
|
data = response.json()
|
|
if 'scrobbles' in data:
|
|
accepted = data['scrobbles'].get('@attr', {}).get('accepted', 0)
|
|
logger.info(f"Last.fm scrobbled {accepted}/{len(batch)} tracks")
|
|
return True
|
|
else:
|
|
error = data.get('error', 'Unknown')
|
|
logger.warning(f"Last.fm scrobble failed: {data.get('message', error)}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Last.fm scrobble error: {e}")
|
|
return False
|
|
|
|
def _make_request(self, method: str, params: Dict = None, timeout: int = 10, raise_on_transient: bool = False) -> Optional[Dict]:
|
|
"""Make a request to the Last.fm API.
|
|
|
|
Args:
|
|
raise_on_transient: If True, raise exceptions on transient errors (timeouts, HTTP errors)
|
|
instead of returning None. Used by get_*_info methods so the worker can distinguish
|
|
'not found' (mark not_found, retry in 30 days) from 'API failed' (mark error, retry in 7 days).
|
|
"""
|
|
if not self.api_key:
|
|
logger.warning("Last.fm API key not configured")
|
|
return None
|
|
|
|
request_params = {
|
|
'method': method,
|
|
'api_key': self.api_key,
|
|
'format': 'json'
|
|
}
|
|
if params:
|
|
request_params.update(params)
|
|
|
|
try:
|
|
response = self.session.get(
|
|
self.BASE_URL,
|
|
params=request_params,
|
|
timeout=timeout
|
|
)
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
# Last.fm returns errors inside the JSON
|
|
if 'error' in data:
|
|
error_code = data.get('error')
|
|
error_msg = data.get('message', 'Unknown error')
|
|
# Error 6 = "Artist/Album/Track not found" — not a real error
|
|
if error_code == 6:
|
|
return None
|
|
# Transient errors: 11=Service Offline, 16=Temporarily Unavailable, 29=Rate Limit
|
|
if raise_on_transient and error_code in (11, 16, 29):
|
|
raise Exception(f"Last.fm transient error ({error_code}): {error_msg}")
|
|
logger.error(f"Last.fm API error ({error_code}): {error_msg}")
|
|
return None
|
|
|
|
return data
|
|
|
|
except requests.exceptions.Timeout:
|
|
logger.warning(f"Last.fm API timeout for method: {method}")
|
|
if raise_on_transient:
|
|
raise
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"Last.fm API request error ({method}): {e}")
|
|
if raise_on_transient:
|
|
raise
|
|
return None
|
|
|
|
# ── Artist Methods ──
|
|
|
|
@rate_limited
|
|
def search_artist(self, artist_name: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Search for an artist by name.
|
|
|
|
Returns:
|
|
Artist dict with: name, mbid, url, listeners, image
|
|
"""
|
|
data = self._make_request('artist.search', {
|
|
'artist': artist_name,
|
|
'limit': 5
|
|
})
|
|
if not data:
|
|
return None
|
|
|
|
results = data.get('results', {}).get('artistmatches', {}).get('artist', [])
|
|
if results and len(results) > 0:
|
|
logger.debug(f"Found artist for query: {artist_name}")
|
|
return results[0]
|
|
|
|
logger.debug(f"No artist found for query: {artist_name}")
|
|
return None
|
|
|
|
@rate_limited
|
|
def get_artist_info(self, artist_name: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get detailed artist info including bio, tags, stats.
|
|
|
|
Returns:
|
|
Artist dict with: name, mbid, url, image, stats (listeners, playcount),
|
|
similar (artists), tags (tag list), bio (summary, content)
|
|
"""
|
|
data = self._make_request('artist.getinfo', {
|
|
'artist': artist_name,
|
|
'autocorrect': 1
|
|
}, raise_on_transient=True)
|
|
if not data:
|
|
return None
|
|
|
|
artist = data.get('artist')
|
|
if artist:
|
|
logger.debug(f"Got artist info for: {artist_name}")
|
|
return artist
|
|
|
|
return None
|
|
|
|
@rate_limited
|
|
def get_artist_top_tags(self, artist_name: str) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get top tags for an artist (genres, styles, moods).
|
|
|
|
Returns:
|
|
List of tag dicts with: name, count, url
|
|
"""
|
|
data = self._make_request('artist.gettoptags', {
|
|
'artist': artist_name,
|
|
'autocorrect': 1
|
|
})
|
|
if not data:
|
|
return []
|
|
|
|
tags = data.get('toptags', {}).get('tag', [])
|
|
return tags if isinstance(tags, list) else [tags] if tags else []
|
|
|
|
@rate_limited
|
|
def get_artist_top_tracks(self, artist_name: str, limit: int = 5) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get top tracks for an artist.
|
|
|
|
Returns:
|
|
List of track dicts with: name, playcount, listeners, url
|
|
"""
|
|
data = self._make_request('artist.gettoptracks', {
|
|
'artist': artist_name,
|
|
'autocorrect': 1,
|
|
'limit': limit
|
|
})
|
|
if not data:
|
|
return []
|
|
|
|
tracks = data.get('toptracks', {}).get('track', [])
|
|
if not isinstance(tracks, list):
|
|
tracks = [tracks] if tracks else []
|
|
|
|
result = []
|
|
for t in tracks:
|
|
result.append({
|
|
'name': t.get('name', ''),
|
|
'playcount': int(t.get('playcount', 0)),
|
|
'listeners': int(t.get('listeners', 0)),
|
|
'url': t.get('url', ''),
|
|
})
|
|
return result
|
|
|
|
@rate_limited
|
|
def get_similar_artists(self, artist_name: str, limit: int = 10) -> List[Dict[str, Any]]:
|
|
"""
|
|
Get similar artists.
|
|
|
|
Returns:
|
|
List of artist dicts with: name, mbid, match (similarity score), url, image
|
|
"""
|
|
data = self._make_request('artist.getsimilar', {
|
|
'artist': artist_name,
|
|
'autocorrect': 1,
|
|
'limit': limit
|
|
})
|
|
if not data:
|
|
return []
|
|
|
|
artists = data.get('similarartists', {}).get('artist', [])
|
|
return artists if isinstance(artists, list) else [artists] if artists else []
|
|
|
|
# ── Album Methods ──
|
|
|
|
@rate_limited
|
|
def search_album(self, artist_name: str, album_title: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Search for an album.
|
|
|
|
Returns:
|
|
Album dict with: name, artist, url, image
|
|
"""
|
|
data = self._make_request('album.search', {
|
|
'album': f"{artist_name} {album_title}",
|
|
'limit': 5
|
|
})
|
|
if not data:
|
|
return None
|
|
|
|
results = data.get('results', {}).get('albummatches', {}).get('album', [])
|
|
if results and len(results) > 0:
|
|
logger.debug(f"Found album for query: {artist_name} - {album_title}")
|
|
return results[0]
|
|
|
|
logger.debug(f"No album found for query: {artist_name} - {album_title}")
|
|
return None
|
|
|
|
@rate_limited
|
|
def get_album_info(self, artist_name: str, album_title: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get detailed album info including tags, tracks, wiki.
|
|
|
|
Returns:
|
|
Album dict with: name, artist, mbid, url, image, listeners, playcount,
|
|
tracks (track list), tags (tag list), wiki (summary, content)
|
|
"""
|
|
data = self._make_request('album.getinfo', {
|
|
'artist': artist_name,
|
|
'album': album_title,
|
|
'autocorrect': 1
|
|
}, raise_on_transient=True)
|
|
if not data:
|
|
return None
|
|
|
|
album = data.get('album')
|
|
if album:
|
|
logger.debug(f"Got album info for: {artist_name} - {album_title}")
|
|
return album
|
|
|
|
return None
|
|
|
|
# ── Track Methods ──
|
|
|
|
@rate_limited
|
|
def search_track(self, artist_name: str, track_title: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Search for a track.
|
|
|
|
Returns:
|
|
Track dict with: name, artist, url, listeners
|
|
"""
|
|
data = self._make_request('track.search', {
|
|
'track': track_title,
|
|
'artist': artist_name,
|
|
'limit': 5
|
|
})
|
|
if not data:
|
|
return None
|
|
|
|
results = data.get('results', {}).get('trackmatches', {}).get('track', [])
|
|
if results and len(results) > 0:
|
|
logger.debug(f"Found track for query: {artist_name} - {track_title}")
|
|
return results[0]
|
|
|
|
logger.debug(f"No track found for query: {artist_name} - {track_title}")
|
|
return None
|
|
|
|
@rate_limited
|
|
def get_track_info(self, artist_name: str, track_title: str) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Get detailed track info including tags, wiki, play stats.
|
|
|
|
Returns:
|
|
Track dict with: name, mbid, url, duration, listeners, playcount,
|
|
artist, album, toptags (tag list), wiki (summary, content)
|
|
"""
|
|
data = self._make_request('track.getinfo', {
|
|
'artist': artist_name,
|
|
'track': track_title,
|
|
'autocorrect': 1
|
|
}, raise_on_transient=True)
|
|
if not data:
|
|
return None
|
|
|
|
track = data.get('track')
|
|
if track:
|
|
logger.debug(f"Got track info for: {artist_name} - {track_title}")
|
|
return track
|
|
|
|
return None
|
|
|
|
# ── Utility Methods ──
|
|
|
|
def get_best_image(self, images: List) -> Optional[str]:
|
|
"""
|
|
Extract the best quality image URL from Last.fm image array.
|
|
Last.fm returns images as [{#text: url, size: small/medium/large/extralarge/mega}]
|
|
"""
|
|
if not images or not isinstance(images, list):
|
|
return None
|
|
|
|
# Prefer largest
|
|
for size in ['mega', 'extralarge', 'large', 'medium', 'small']:
|
|
for img in images:
|
|
if isinstance(img, dict) and img.get('size') == size:
|
|
url = img.get('#text', '')
|
|
if url:
|
|
return url
|
|
|
|
return None
|
|
|
|
def extract_tags(self, tags_data, max_tags: int = 10) -> List[str]:
|
|
"""
|
|
Extract tag names from Last.fm tags response.
|
|
Filters out low-count tags and normalizes.
|
|
"""
|
|
if not tags_data:
|
|
return []
|
|
|
|
tag_list = tags_data if isinstance(tags_data, list) else tags_data.get('tag', [])
|
|
if not isinstance(tag_list, list):
|
|
tag_list = [tag_list] if tag_list else []
|
|
|
|
tags = []
|
|
for tag in tag_list[:max_tags]:
|
|
if isinstance(tag, dict):
|
|
name = tag.get('name', '').strip()
|
|
if name and len(name) > 1:
|
|
tags.append(name)
|
|
elif isinstance(tag, str):
|
|
tags.append(tag.strip())
|
|
|
|
return tags
|
|
|
|
def validate_api_key(self) -> bool:
|
|
"""Test if the API key is valid by making a simple request"""
|
|
if not self.api_key:
|
|
return False
|
|
|
|
data = self._make_request('chart.gettopartists', {'limit': 1})
|
|
return data is not None
|