import spotipy from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials from typing import Dict, List, Optional, Any import re import time import threading from functools import wraps from dataclasses import dataclass from utils.logging_config import get_logger from config.settings import config_manager from core.metadata.artist_album_cache import get_cached_artist_album_items, store_artist_album_items from core.metadata.cache import get_metadata_cache logger = get_logger("spotify_client") def _upgrade_spotify_image_url(url: str) -> str: """Upgrade a Spotify CDN image URL to the highest available resolution. Album art URLs use the prefix 'ab67616d' and encode size as the 4-char hex segment following '0000' (e.g. 'b273' = 640x640, '1e02' = 300x300). Replacing it with '82c1' requests the original uploaded master (up to 2000px+). This only applies to album art (ab67616d prefix). Artist images use a different prefix (ab676161) and the 82c1 trick does not work for them — those are left as-is since Spotify already returns them sorted largest-first. """ if not url or 'i.scdn.co' not in url: return url return re.sub(r'(/image/ab67616d)0000[0-9a-f]{4}', r'\g<1>000082c1', url) # Global rate limiting variables _last_api_call_time = 0 _api_call_lock = threading.Lock() MIN_API_INTERVAL = 0.35 # Default: 350ms between API calls (~171/min, under Spotify's ~180/min limit) def _get_min_api_interval(): """Get configurable API interval from settings, falling back to default.""" try: from config.settings import config_manager val = config_manager.get('spotify.min_api_interval', None) if val is not None: return max(0.1, float(val)) # Floor at 100ms to prevent abuse except Exception as e: logger.debug("get min_api_interval setting: %s", e) return MIN_API_INTERVAL # Request queuing for burst handling import queue _request_queue = queue.Queue() _queue_processor_running = False # Global rate limit ban state — when Spotify returns a long Retry-After (>60s), # we set this so ALL API calls are suppressed until the ban expires. _rate_limit_lock = threading.Lock() _rate_limit_until = 0 # Unix timestamp when the ban expires (0 = not banned) _rate_limit_retry_after = 0 # Original Retry-After value in seconds _rate_limit_endpoint = None # Which function triggered the ban _rate_limit_set_at = 0 # When the ban was set _rate_limit_ban_ended_at = 0 # When the last ban expired naturally (for post-ban cooldown) _rate_limit_hit_count = 0 # How many times we've been rate limited recently (for escalation) _rate_limit_first_hit = 0 # Timestamp of the first hit in the current escalation window # Threshold: if Retry-After exceeds this, activate global ban instead of sleeping _LONG_RATE_LIMIT_THRESHOLD = 60 # seconds # After a ban expires, wait this long before making any auth probe calls. # This prevents the "immediate re-probe → re-ban" cycle where Spotify's # server-side cooldown outlasts the Retry-After (or our default ban # duration) we used. A user who'd just sat through a 4-hour MAX_RETRIES # ban had it expire, hit our 5-minute cooldown, made a single # get_artist_albums call 32 seconds after the cooldown ended, and got # slapped with another 4-hour ban — the post-ban cooldown was too short # for Spotify's server to forget the previous offense. 30 minutes is a # better empirical floor; can be revisited if reports persist. _POST_BAN_COOLDOWN = 1800 # 30 minutes # Escalation: if we get rate limited again within this window, increase ban duration _ESCALATION_WINDOW = 3600 # 1 hour — if re-limited within this, escalate _ESCALATION_MAX = 14400 # 4 hours max ban _BASE_UNKNOWN_BAN = 1800 # 30 min default when Retry-After header is missing _BASE_MAX_RETRIES_BAN = 14400 # 4 hours default when spotipy exhausted all retries (severe rate limit) class SpotifyRateLimitError(Exception): """Raised when Spotify API calls are blocked due to active global rate limit ban.""" def __init__(self, retry_after, endpoint=None): self.retry_after = retry_after self.endpoint = endpoint super().__init__(f"Spotify rate limited for {retry_after}s (triggered by {endpoint})") def _set_global_rate_limit(retry_after_seconds, endpoint_name, has_real_header=False): """Activate the global rate limit ban. Escalates duration on repeated hits.""" global _rate_limit_until, _rate_limit_retry_after, _rate_limit_endpoint, _rate_limit_set_at global _rate_limit_hit_count, _rate_limit_first_hit with _rate_limit_lock: now = time.time() # Escalation: if we're hitting rate limits repeatedly, increase the ban if not has_real_header: # Only escalate when we don't have a real Retry-After (i.e., we're guessing) if now - _rate_limit_first_hit < _ESCALATION_WINDOW and _rate_limit_first_hit > 0: _rate_limit_hit_count += 1 else: # New escalation window _rate_limit_hit_count = 1 _rate_limit_first_hit = now if _rate_limit_hit_count > 1: # Double the ban for each repeated hit, up to max escalated = retry_after_seconds * (2 ** (_rate_limit_hit_count - 1)) retry_after_seconds = min(escalated, _ESCALATION_MAX) logger.warning( f"Rate limit escalation: hit #{_rate_limit_hit_count} within window, " f"ban escalated to {retry_after_seconds}s" ) new_until = now + retry_after_seconds # Only update if this extends the existing ban if new_until > _rate_limit_until: _rate_limit_until = new_until _rate_limit_retry_after = retry_after_seconds _rate_limit_endpoint = endpoint_name _rate_limit_set_at = now logger.warning( f"GLOBAL RATE LIMIT ACTIVATED: {retry_after_seconds}s ban " f"(expires {time.strftime('%H:%M:%S', time.localtime(new_until))}) " f"triggered by {endpoint_name}" ) # Record event for debug diagnostics try: from core.api_call_tracker import api_call_tracker escalated = _rate_limit_hit_count > 1 api_call_tracker.record_event( 'spotify', 'rate_limit_ban', endpoint=endpoint_name, duration=retry_after_seconds, detail=f'{"escalation #" + str(_rate_limit_hit_count) if escalated else "initial"}' f'{", real Retry-After" if has_real_header else ", estimated"}' ) except Exception as e: logger.debug("api_call_tracker record rate_limit_ban: %s", e) try: from core.metadata.status import publish_spotify_status publish_spotify_status( connected=False, authenticated=True, rate_limited=True, rate_limit=_get_rate_limit_info(), post_ban_cooldown=_get_post_ban_cooldown_remaining() or None, ) except Exception as e: logger.debug("publish_spotify_status set rate limit: %s", e) def _is_globally_rate_limited(): """Check if the global rate limit ban is active.""" global _rate_limit_ban_ended_at with _rate_limit_lock: if _rate_limit_until <= 0: return False if time.time() >= _rate_limit_until: # Ban expired — record when it ended so post-ban cooldown can apply if _rate_limit_ban_ended_at < _rate_limit_until: _rate_limit_ban_ended_at = time.time() logger.info("Rate limit ban expired, entering post-ban cooldown period") return False return True def _is_in_post_ban_cooldown(): """Check if we're in the post-ban cooldown period. After a ban expires, we wait _POST_BAN_COOLDOWN seconds before allowing auth probes to prevent the re-probe → re-ban cycle.""" with _rate_limit_lock: if _rate_limit_ban_ended_at <= 0: return False elapsed = time.time() - _rate_limit_ban_ended_at if elapsed < _POST_BAN_COOLDOWN: return True return False def _get_post_ban_cooldown_remaining(): """Get remaining seconds in post-ban cooldown, or 0 if not in cooldown.""" with _rate_limit_lock: if _rate_limit_ban_ended_at <= 0: return 0 remaining = _POST_BAN_COOLDOWN - (time.time() - _rate_limit_ban_ended_at) return max(0, int(remaining)) def _get_rate_limit_info(): """Get current rate limit ban details. Returns None if not rate limited.""" with _rate_limit_lock: if _rate_limit_until <= 0: return None now = time.time() remaining = _rate_limit_until - now if remaining <= 0: return None return { 'active': True, 'remaining_seconds': int(remaining), 'retry_after': _rate_limit_retry_after, 'endpoint': _rate_limit_endpoint, 'set_at': _rate_limit_set_at, 'expires_at': _rate_limit_until } def _clear_rate_limit(): """Manually clear the global rate limit ban AND post-ban cooldown. Used by disconnect/reconnect so the user can immediately retry.""" global _rate_limit_until, _rate_limit_retry_after, _rate_limit_endpoint, _rate_limit_set_at, _rate_limit_ban_ended_at global _rate_limit_hit_count, _rate_limit_first_hit with _rate_limit_lock: _rate_limit_until = 0 _rate_limit_retry_after = 0 _rate_limit_endpoint = None _rate_limit_set_at = 0 _rate_limit_ban_ended_at = 0 _rate_limit_hit_count = 0 _rate_limit_first_hit = 0 logger.info("Global rate limit ban cleared (including post-ban cooldown)") try: from core.metadata.status import publish_spotify_status publish_spotify_status( rate_limited=False, rate_limit=None, post_ban_cooldown=None, ) except Exception as e: logger.debug("publish_spotify_status clear rate limit: %s", e) def _detect_and_set_rate_limit(exception, endpoint_name="unknown"): """Check if a Spotify exception is a 429 rate limit and activate global ban if so. Returns True if rate limit was detected.""" error_str = str(exception) # Check both string matching and http_status attribute (SpotifyException has it) is_429 = getattr(exception, 'http_status', None) == 429 is_rate_limit_str = "429" in error_str or "rate limit" in error_str.lower() if is_429 or is_rate_limit_str: # Try to extract Retry-After from exception headers retry_after = None has_real_header = False # Method 1: SpotifyException.headers (set by spotipy with retries=0) exc_headers = getattr(exception, 'headers', None) if exc_headers and hasattr(exc_headers, 'get'): retry_after = exc_headers.get('Retry-After') or exc_headers.get('retry-after') if retry_after: logger.info(f"Extracted Retry-After from exception headers: {retry_after}") else: logger.debug(f"Exception has headers but no Retry-After key. Headers type: {type(exc_headers).__name__}, keys: {list(exc_headers.keys())[:10] if hasattr(exc_headers, 'keys') else 'N/A'}") # Method 2: Parse from error message (some spotipy versions embed it) if not retry_after: import re ra_match = re.search(r'[Rr]etry[- ][Aa]fter[:\s]+(\d+)', error_str) if ra_match: retry_after = ra_match.group(1) logger.info(f"Extracted Retry-After from error message: {retry_after}") if retry_after: try: delay = int(retry_after) has_real_header = True logger.info(f"Rate limit detected on {endpoint_name} — Retry-After header: {delay}s") except (ValueError, TypeError): delay = _BASE_UNKNOWN_BAN logger.warning(f"Rate limit detected on {endpoint_name} — unparseable Retry-After: {retry_after}") else: # No Retry-After header available if "max retries" in error_str.lower(): # Spotipy exhausted all retries on 429s — this is a severe ban. # Spotify's actual Retry-After is consumed internally by spotipy and not # passed in the exception. Use a long default to avoid re-triggering. delay = _BASE_MAX_RETRIES_BAN # 4 hours else: delay = _BASE_UNKNOWN_BAN # 30 min logger.warning(f"Rate limit detected on {endpoint_name} — no Retry-After header, using {delay}s default") _set_global_rate_limit(delay, endpoint_name, has_real_header=has_real_header) return True return False def rate_limited(func): """Decorator to enforce rate limiting on Spotify API calls with retry and exponential backoff""" @wraps(func) def wrapper(*args, **kwargs): global _last_api_call_time # Pre-flight check: if globally rate limited, don't even attempt the API call. # Let the method body run so its internal is_spotify_authenticated() check # returns False and iTunes fallback logic can execute. if _is_globally_rate_limited(): return func(*args, **kwargs) max_retries = 5 for attempt in range(max_retries + 1): # Re-check ban before each retry — a previous attempt may have triggered one if _is_globally_rate_limited(): raise SpotifyRateLimitError(0, func.__name__) # Enforce minimum interval between API calls (configurable via settings) _interval = _get_min_api_interval() with _api_call_lock: current_time = time.time() time_since_last_call = current_time - _last_api_call_time if time_since_last_call < _interval: sleep_time = _interval - time_since_last_call time.sleep(sleep_time) _last_api_call_time = time.time() from core.api_call_tracker import api_call_tracker api_call_tracker.record_call('spotify', endpoint=func.__name__) try: return func(*args, **kwargs) except SpotifyRateLimitError: raise # Don't retry our own ban errors except Exception as e: error_str = str(e).lower() is_rate_limit = "rate limit" in error_str or "429" in str(e) is_server_error = "502" in str(e) or "503" in str(e) if is_rate_limit: # Try to extract Retry-After from spotipy exception headers retry_after = None if hasattr(e, 'headers') and e.headers: retry_after = e.headers.get('Retry-After') or e.headers.get('retry-after') if retry_after: try: delay = int(retry_after) except (ValueError, TypeError): delay = None # If Retry-After is long, activate global ban instead of sleeping if delay and delay > _LONG_RATE_LIMIT_THRESHOLD: _set_global_rate_limit(delay, func.__name__, has_real_header=True) raise SpotifyRateLimitError(delay, func.__name__) from e if delay: delay = delay + 1 else: delay = 3.0 * (2 ** attempt) else: delay = 3.0 * (2 ** attempt) # 3, 6, 12, 24, 48 if attempt < max_retries: logger.warning(f"Spotify rate limit hit, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}") time.sleep(delay) continue else: # All retries exhausted on 429s — activate global ban. # Don't trust the Retry-After header here — we already retried # with it multiple times and still got 429'd, so it's too short. _set_global_rate_limit(_BASE_MAX_RETRIES_BAN, func.__name__) elif is_server_error and attempt < max_retries: delay = 2.0 * (2 ** attempt) # 2, 4, 8, 16, 32 logger.warning(f"Spotify server error, retrying in {delay:.0f}s (attempt {attempt + 1}/{max_retries}): {func.__name__}") time.sleep(delay) continue raise return wrapper @dataclass class Track: id: str name: str artists: List[str] album: str duration_ms: int popularity: int preview_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None image_url: Optional[str] = None release_date: Optional[str] = None album_type: Optional[str] = None total_tracks: Optional[int] = None @classmethod def from_spotify_track(cls, track_data: Dict[str, Any]) -> 'Track': # Extract album image (largest available — Spotify returns images sorted largest first) album_image_url = None if 'album' in track_data and 'images' in track_data['album']: images = track_data['album']['images'] if images: album_image_url = _upgrade_spotify_image_url(images[0]['url']) return cls( id=track_data['id'], name=track_data['name'], artists=[artist['name'] for artist in track_data['artists']], album=track_data['album']['name'], duration_ms=track_data['duration_ms'], popularity=track_data.get('popularity', 0), preview_url=track_data.get('preview_url'), external_urls=track_data.get('external_urls'), image_url=album_image_url, release_date=track_data.get('album', {}).get('release_date'), album_type=track_data.get('album', {}).get('album_type'), total_tracks=track_data.get('album', {}).get('total_tracks') ) @dataclass class Artist: id: str name: str popularity: int genres: List[str] followers: int image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None @classmethod def from_spotify_artist(cls, artist_data: Dict[str, Any]) -> 'Artist': # Get the largest image URL if available image_url = None if artist_data.get('images') and len(artist_data['images']) > 0: image_url = _upgrade_spotify_image_url(artist_data['images'][0]['url']) return cls( id=artist_data['id'], name=artist_data['name'], popularity=artist_data.get('popularity', 0), genres=artist_data.get('genres', []), followers=artist_data.get('followers', {}).get('total', 0), image_url=image_url, external_urls=artist_data.get('external_urls') ) @dataclass class Album: id: str name: str artists: List[str] release_date: str total_tracks: int album_type: str image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None artist_ids: Optional[List[str]] = None @classmethod def from_spotify_album(cls, album_data: Dict[str, Any]) -> 'Album': # Get the largest image URL if available image_url = None if album_data.get('images') and len(album_data['images']) > 0: image_url = _upgrade_spotify_image_url(album_data['images'][0]['url']) return cls( id=album_data['id'], name=album_data['name'], artists=[artist['name'] for artist in album_data['artists']], release_date=album_data.get('release_date', ''), total_tracks=album_data.get('total_tracks', 0), album_type=album_data.get('album_type', 'album'), image_url=image_url, external_urls=album_data.get('external_urls'), artist_ids=[artist['id'] for artist in album_data['artists']] ) # The web UI's virtual "Liked Songs" playlist id. There is NO real playlist # behind a user's liked songs — Spotify serves them via /me/tracks (saved # tracks), and passing this id to the playlist endpoint 400s with # "Unsupported URL / URI". Anything resolving playlists by id must special-case # it (get_playlist_by_id does). LIKED_SONGS_PLAYLIST_ID = "spotify:liked-songs" @dataclass class Playlist: id: str name: str description: Optional[str] owner: str public: bool collaborative: bool tracks: List[Track] total_tracks: int @classmethod def from_spotify_playlist(cls, playlist_data: Dict[str, Any], tracks: List[Track]) -> 'Playlist': return cls( id=playlist_data['id'], name=playlist_data['name'], description=playlist_data.get('description'), owner=playlist_data['owner']['display_name'], public=playlist_data['public'], collaborative=playlist_data['collaborative'], tracks=tracks, total_tracks=(playlist_data.get('tracks') or playlist_data.get('items') or {}).get('total', 0) ) def describe_spotify_unavailable(*, configured: bool, rate_limited: bool, ban_seconds_left: int = 0, in_cooldown: bool = False, cooldown_seconds_left: int = 0, has_token: bool = True) -> str: """Human reason Spotify can't serve a request right now. ``is_spotify_authenticated()`` returns False for five distinct reasons, but every API call site logged the same bare "Not authenticated with Spotify" — so a rate-limit ban looked like a logout. This maps the real state to a clear message (priority matches is_spotify_authenticated): not-configured → rate-limited → post-ban cooldown → no-token → unknown probe failure. Pure. """ if not configured: return "Spotify not configured (add credentials in Settings)" if rate_limited: if ban_seconds_left and ban_seconds_left > 0: return f"Spotify rate-limited — ban ~{max(1, round(ban_seconds_left / 60))}m left (not a logout)" return "Spotify rate-limited — waiting out a ban (not a logout)" if in_cooldown: if cooldown_seconds_left and cooldown_seconds_left > 0: return f"Spotify post-ban cooldown — ~{cooldown_seconds_left}s left (not a logout)" return "Spotify post-ban cooldown (not a logout)" if not has_token: return "Spotify not connected — no saved token; re-authenticate in Settings" return "Spotify auth check failed (token refresh may have failed — see debug log)" class SpotifyClient: def __init__(self): self.sp: Optional[spotipy.Spotify] = None self.user_id: Optional[str] = None self._itunes_client = None # Lazy-loaded iTunes fallback self._deezer_client = None # Lazy-loaded Deezer fallback self._discogs_client = None # Lazy-loaded Discogs fallback self._auth_cache_lock = threading.Lock() self._auth_cached_result: Optional[bool] = None self._auth_cache_time: float = 0 self._AUTH_CACHE_TTL = 900 # 15 minutes — auth status doesn't change mid-session self._setup_client() def _is_spotify_id(self, id_str: str) -> bool: """Check if an ID is a Spotify ID (alphanumeric) vs a fallback source ID (numeric only)""" if not id_str: return False # Spotify IDs contain letters and numbers; iTunes/Deezer IDs are purely numeric return not id_str.isdigit() def _is_itunes_id(self, id_str: str) -> bool: """Check if an ID is numeric (iTunes or Deezer format, not Spotify)""" if not id_str: return False return id_str.isdigit() @property def _itunes(self): """Lazy-load iTunes client""" if self._itunes_client is None: from core.itunes_client import iTunesClient self._itunes_client = iTunesClient() logger.info("iTunes fallback client initialized") return self._itunes_client @property def _deezer(self): """Lazy-load Deezer client for metadata fallback""" if self._deezer_client is None: from core.deezer_client import DeezerClient self._deezer_client = DeezerClient() logger.info("Deezer fallback client initialized") return self._deezer_client @property def _discogs(self): """Lazy-load Discogs client for metadata fallback""" if self._discogs_client is None: from core.discogs_client import DiscogsClient self._discogs_client = DiscogsClient() logger.info("Discogs fallback client initialized") return self._discogs_client @property def _fallback_source(self) -> str: """Get configured primary metadata source for internal fallback routing.""" try: return config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' except Exception: return 'deezer' @property def _fallback(self): """Get the active fallback metadata client based on settings""" if self._fallback_source == 'deezer': return self._deezer if self._fallback_source == 'discogs': # Only use Discogs if token is configured token = config_manager.get('discogs.token', '') if token: return self._discogs return self._itunes # Fall back to iTunes if no Discogs token return self._itunes def _free_selected(self) -> bool: """Whether the user picked 'Spotify Free' as their metadata source (the no-creds option, for when they haven't connected Spotify).""" try: return bool(config_manager.get('metadata.spotify_free', False)) except Exception: return False def _free_installed(self) -> bool: from core.spotify_free_metadata import spotify_free_installed return spotify_free_installed() def _free_wanted(self) -> bool: """Does the user actually want the free source? OPT-IN: only when they picked 'Spotify Free'. This keeps free from auto-scraping for anyone who didn't choose it — a plain-'Spotify' user just waits out a rate-limit ban as before. A user on 'Spotify Free' who also connects an account uses the official account normally and free only bridges their bans.""" return self._free_selected() def _free_available(self) -> bool: """Free CAN serve: package installed AND the user wants Spotify.""" return self._free_installed() and self._free_wanted() def is_spotify_metadata_available(self) -> bool: """Whether SoulSync can serve Spotify metadata — real auth OR the no-creds source. Availability gates (search resolve, enrichment worker, watchlist) use THIS instead of ``is_spotify_authenticated()`` so the free source is reachable. Does NOT change auth semantics.""" from core.spotify_free_metadata import should_offer_spotify_metadata # The enrichment worker's prefer-free opt-in (set on its own client) # makes the no-auth source the active path even without auth or the # 'no-auth Spotify' source choice — so metadata IS available to it. This # only fires on a client carrying _prefer_free (the worker's), so # interactive/watchlist availability is unchanged. if getattr(self, '_prefer_free', False) and self._free_installed(): return True try: authed = self.is_spotify_authenticated() except Exception: authed = False return should_offer_spotify_metadata(authed, self._free_available()) def _free_active(self) -> bool: """Whether the no-creds source may serve THIS request: free available AND official can't (no auth, or rate-limited). ``is_spotify_authenticated()`` already returns False during a rate-limit ban; the explicit rate-limit term covers the brief window before the auth cache refreshes. When authed + healthy the official path returns first, so this never opens. Activations that fall out of this: a no-auth user who chose Spotify Free (free is their source), a connected user mid-rate-limit (free bridges the ban), a connected user who has spent the enrichment worker's real-API daily budget (``_budget_exhausted_use_free``, set by the worker) — so a Spotify-Free user is never paused by the budget — and the worker opt-in below. See _free_wanted().""" from core.spotify_free_metadata import should_use_free_fallback # Worker opt-in (metadata.spotify_free_enrichment): prefer the no-creds # source for enrichment even while authed + healthy + under budget, to # spare the official quota for interactive use. The flag IS the explicit # opt-in, so it only needs the package installed — not the 'Spotify Free' # metadata-source choice — and it's set only on the enrichment worker's # own client, so interactive search/resolve stay official-first. if getattr(self, '_prefer_free', False) and self._free_installed(): return True if not self._free_available(): return False try: authed = self.is_spotify_authenticated() except Exception: authed = False return should_use_free_fallback( authed, _is_globally_rate_limited(), getattr(self, '_budget_exhausted_use_free', False), ) @property def _free_meta(self): """Lazy SpotipyFree-backed metadata client (soft import — absence is not fatal; methods degrade to the iTunes/Deezer fallback).""" client = getattr(self, '_free_meta_client', None) if client is None: from core.spotify_free_metadata import SpotifyFreeMetadataClient client = SpotifyFreeMetadataClient() self._free_meta_client = client return client def reload_config(self): """Reload configuration and re-initialize client""" self._invalidate_auth_cache() self._setup_client() def _setup_client(self): config = config_manager.get_spotify_config() if not config.get('client_id') or not config.get('client_secret'): logger.warning("Spotify credentials not configured") return try: # Tokens live in the database-backed config store, not a loose # file: config/.spotify_cache sat in /app/config, which is only # persistent when the user's compose maps it — an anonymous # volume is recreated empty on every container pull, so tokens # died nightly while every other setting survived ("it keeps # unauthenticating" daily, wolf39us). The handler imports the # legacy file once if the store is empty. from core.spotify_token_cache import DatabaseTokenCache auth_manager = SpotifyOAuth( client_id=config['client_id'], client_secret=config['client_secret'], redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"), scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read", cache_handler=DatabaseTokenCache(config_manager) ) self.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15) # retries=0: prevent spotipy from sleeping for Retry-After duration on 429s # (can be hours). Our rate_limited decorator + global ban handle retries instead. # requests_timeout=15: prevent any single request from hanging indefinitely. # Don't fetch user info on startup - do it lazily to avoid blocking UI self.user_id = None logger.info("Spotify client initialized (user info will be fetched when needed)") except Exception as e: logger.error(f"Failed to authenticate with Spotify: {e}") self.sp = None def is_authenticated(self) -> bool: """ Check if client can service metadata requests. Returns True if Spotify is authenticated OR fallback (iTunes/Deezer) is available. For Spotify-specific auth check, use is_spotify_authenticated(). """ # If Spotify is authenticated, we're good if self.is_spotify_authenticated(): return True # Fallback (iTunes or Deezer) is always available — no auth required return True def _invalidate_auth_cache(self): """Clear the auth cache so the next check makes a fresh API call""" with self._auth_cache_lock: self._auth_cached_result = None self._auth_cache_time = 0 def is_spotify_authenticated(self) -> bool: """Check if Spotify client is specifically authenticated (not just iTunes fallback). Results are cached for 60 seconds to avoid excessive API calls. During rate limit bans and post-ban cooldown, returns False without making API calls.""" rate_limited_state = False if self.sp is None: try: from core.metadata.status import publish_spotify_status publish_spotify_status( connected=False, authenticated=False, rate_limited=_is_globally_rate_limited(), rate_limit=_get_rate_limit_info(), post_ban_cooldown=_get_post_ban_cooldown_remaining() or None, ) except Exception as e: logger.debug("publish_spotify_status no-client: %s", e) return False # If globally rate limited, report as NOT authenticated so callers # skip Spotify and fall through to iTunes fallback naturally. # This prevents any API calls that could extend the ban. if _is_globally_rate_limited(): try: from core.metadata.status import publish_spotify_status publish_spotify_status( connected=False, authenticated=True, rate_limited=True, rate_limit=_get_rate_limit_info(), post_ban_cooldown=_get_post_ban_cooldown_remaining() or None, ) except Exception as e: logger.debug("publish_spotify_status rate-limited: %s", e) return False # Post-ban cooldown: after a ban expires, don't probe Spotify immediately. # Spotify's server-side cooldown can outlast the Retry-After they sent us, # so probing right away would just re-trigger the ban. if _is_in_post_ban_cooldown(): remaining = _get_post_ban_cooldown_remaining() logger.debug(f"Post-ban cooldown active ({remaining}s left), skipping auth probe") try: from core.metadata.status import publish_spotify_status publish_spotify_status( connected=False, authenticated=True, rate_limited=False, rate_limit=None, post_ban_cooldown=remaining or None, ) except Exception as e: logger.debug("publish_spotify_status post-ban cooldown: %s", e) return False # Check cache first (lock only for brief read) with self._auth_cache_lock: if self._auth_cached_result is not None and (time.time() - self._auth_cache_time) < self._AUTH_CACHE_TTL: try: from core.metadata.status import publish_spotify_status publish_spotify_status( connected=self._auth_cached_result, authenticated=self._auth_cached_result, rate_limited=False, rate_limit=None, post_ban_cooldown=None, ) except Exception as e: logger.debug("publish_spotify_status cache hit: %s", e) return self._auth_cached_result # Cache miss — make API call outside the lock. # Safety: if there's no cached token, return False immediately. # Without this guard, spotipy's auth_manager will try to start an interactive # OAuth flow (binding 127.0.0.1:), which inside Docker either # steals Flask's port (crash loop) or binds loopback-only (unreachable from host). # Users authenticate via the SoulSync web UI instead. try: cache_handler = getattr(self.sp.auth_manager, 'cache_handler', None) if cache_handler and cache_handler.get_cached_token() is None: with self._auth_cache_lock: self._auth_cached_result = False self._auth_cache_time = time.time() try: from core.metadata.status import publish_spotify_status publish_spotify_status( connected=False, authenticated=False, rate_limited=False, rate_limit=None, post_ban_cooldown=None, ) except Exception as e: logger.debug("publish_spotify_status no-token: %s", e) return False except Exception as e: logger.debug("cached token probe: %s", e) # Use a dedicated probe client (retries=0) so a 429 here propagates # immediately and we can detect long Retry-After bans. try: probe = spotipy.Spotify(auth_manager=self.sp.auth_manager, retries=0) probe.current_user() result = True except Exception as e: error_str = str(e) # Rate limit means we ARE authenticated — just throttled if "rate" in error_str.lower() or "429" in error_str: # ANY rate limit on the auth probe means Spotify is actively throttling us. # Always activate a global ban — even with a short or missing Retry-After. # Without this, the probe→429→probe cycle repeats every ~60s forever. retry_after = None if hasattr(e, 'headers') and e.headers: retry_after = e.headers.get('Retry-After') or e.headers.get('retry-after') has_real_header = False try: delay = int(retry_after) if retry_after else 0 if retry_after: has_real_header = True except (ValueError, TypeError): delay = 0 # Minimum 30 min for auth probe 429s — these indicate persistent throttling ban_duration = max(delay, _BASE_UNKNOWN_BAN) _set_global_rate_limit(ban_duration, 'is_spotify_authenticated', has_real_header=has_real_header) logger.warning(f"Auth probe rate limited — activating {ban_duration}s global ban") rate_limited_state = True result = True else: logger.debug(f"Spotify authentication check failed: {e}") result = False with self._auth_cache_lock: self._auth_cached_result = result self._auth_cache_time = time.time() try: from core.metadata.status import publish_spotify_status publish_spotify_status( connected=result, authenticated=result, rate_limited=rate_limited_state, rate_limit=_get_rate_limit_info() if rate_limited_state else None, post_ban_cooldown=None, ) except Exception as e: logger.debug("publish_spotify_status auth probe: %s", e) return result def _auth_unavailable_reason(self) -> str: """Real reason an API call can't reach Spotify right now, for logging — distinguishes a rate-limit ban / cooldown from a genuine logout instead of the old catch-all 'Not authenticated'. Side-effect-free: reads the cached token (no refresh, no API probe).""" has_token = True try: if self.sp is not None: ch = getattr(self.sp.auth_manager, 'cache_handler', None) has_token = ch is None or ch.get_cached_token() is not None except Exception: has_token = True # unknown → don't wrongly claim "not connected" rl = _get_rate_limit_info() if _is_globally_rate_limited() else None return describe_spotify_unavailable( configured=self.sp is not None, rate_limited=_is_globally_rate_limited(), ban_seconds_left=(rl or {}).get('remaining_seconds', 0) or 0, in_cooldown=_is_in_post_ban_cooldown(), cooldown_seconds_left=_get_post_ban_cooldown_remaining() or 0, has_token=has_token, ) def disconnect(self): """Disconnect Spotify: clear client, delete cache, invalidate auth cache, clear rate limit""" import os self.sp = None self.user_id = None self._invalidate_auth_cache() _clear_rate_limit() try: from core.metadata.status import publish_spotify_status publish_spotify_status( connected=False, authenticated=False, rate_limited=False, rate_limit=None, post_ban_cooldown=None, ) except Exception as e: logger.debug("publish_spotify_status disconnect: %s", e) try: from core.spotify_token_cache import DatabaseTokenCache DatabaseTokenCache(config_manager).clear() logger.info("Cleared Spotify token cache (database + legacy file)") except Exception as e: logger.warning(f"Failed to clear Spotify token cache: {e}") logger.info("Spotify client disconnected") @staticmethod def is_rate_limited(): """Check if Spotify is globally rate limited.""" return _is_globally_rate_limited() @staticmethod def get_rate_limit_info(): """Get rate limit ban details. Returns None if not rate limited.""" return _get_rate_limit_info() @staticmethod def clear_rate_limit(): """Manually clear the rate limit ban.""" _clear_rate_limit() @staticmethod def get_post_ban_cooldown_remaining(): """Get remaining seconds in post-ban cooldown, or 0 if not in cooldown.""" return _get_post_ban_cooldown_remaining() def _ensure_user_id(self) -> bool: """Ensure user_id is loaded (may make API call)""" if self.user_id is None and self.sp is not None: try: user_info = self.sp.current_user() self.user_id = user_info['id'] logger.info(f"Successfully authenticated with Spotify as {user_info['display_name']}") return True except Exception as e: logger.error(f"Failed to fetch user info: {e}") return False return self.user_id is not None @rate_limited def get_user_playlists(self) -> List[Playlist]: if not self.is_spotify_authenticated(): logger.error("Spotify request skipped — %s", self._auth_unavailable_reason()) return [] if not self._ensure_user_id(): logger.error("Failed to get user ID") return [] playlists = [] try: results = self.sp.current_user_playlists(limit=50) while results: for playlist_data in results['items']: # Spotify API already returns all playlists the user has access to # (owned + followed), so no need to filter logger.info(f"Fetching tracks for playlist: {playlist_data['name']}") tracks = self._get_playlist_tracks(playlist_data['id']) playlist = Playlist.from_spotify_playlist(playlist_data, tracks) playlists.append(playlist) if results['next']: with _api_call_lock: elapsed = time.time() - _last_api_call_time _pi = _get_min_api_interval() if elapsed < _pi: time.sleep(_pi - elapsed) globals()['_last_api_call_time'] = time.time() from core.api_call_tracker import api_call_tracker api_call_tracker.record_call('spotify', endpoint='get_user_playlists_page') results = self.sp.next(results) else: results = None logger.info(f"Retrieved {len(playlists)} playlists") return playlists except Exception as e: logger.error(f"Error fetching user playlists: {e}") return [] @rate_limited def get_user_playlists_metadata_only(self) -> List[Playlist]: """Get playlists without fetching all track details for faster loading""" if not self.is_spotify_authenticated(): logger.error("Spotify request skipped — %s", self._auth_unavailable_reason()) return [] if not self._ensure_user_id(): logger.error("Failed to get user ID") return [] playlists = [] try: # Fetch all playlists using pagination limit = 50 # Maximum allowed by Spotify API offset = 0 total_fetched = 0 logger.info("Beginning fetch of user playlists...") while True: results = self.sp.current_user_playlists(limit=limit, offset=offset) if not results or 'items' not in results: break # Log expected total on first page if offset == 0: expected_total = results.get('total', 'Unknown') logger.info(f"Spotify reports {expected_total} total playlists to fetch.") batch_count = 0 for playlist_data in results['items']: try: # Spotify API already returns all playlists the user has access to # (owned + followed), so no need to filter # Handle potential missing owner data safely if not playlist_data.get('owner'): playlist_data['owner'] = {'display_name': 'Unknown Owner', 'id': 'unknown'} elif not playlist_data['owner'].get('display_name'): playlist_data['owner']['display_name'] = 'Unknown' # Create playlist with empty tracks list for now playlist = Playlist.from_spotify_playlist(playlist_data, []) playlists.append(playlist) batch_count += 1 except Exception as p_error: p_name = playlist_data.get('name', 'Unknown') if playlist_data else 'None' logger.warning(f"Skipping malformed playlist '{p_name}': {p_error}") total_fetched += batch_count logger.info(f"Retrieved {batch_count} playlists in batch (offset {offset}), total so far: {total_fetched}") # Check if we've fetched all playlists if len(results['items']) < limit or not results.get('next'): break offset += limit logger.info(f"Retrieved {len(playlists)} total playlist metadata") return playlists except Exception as e: logger.error(f"Error fetching user playlists metadata: {e}") # Return partial results if we crashed mid-way but have some data if playlists: logger.info(f"Returning {len(playlists)} playlists fetched before error.") return playlists return [] @rate_limited def get_saved_tracks_count(self) -> int: """Get the total count of user's saved/liked songs without fetching all tracks""" if not self.is_spotify_authenticated(): logger.error("Spotify request skipped — %s", self._auth_unavailable_reason()) return 0 try: # Just fetch first page to get the total count results = self.sp.current_user_saved_tracks(limit=1) if results and 'total' in results: total_count = results['total'] logger.info(f"User has {total_count} saved tracks") return total_count return 0 except Exception as e: logger.error(f"Error fetching saved tracks count: {e}") return 0 @rate_limited def get_saved_tracks(self) -> List[Track]: """Fetch all user's saved/liked songs from Spotify""" if not self.is_spotify_authenticated(): logger.error("Spotify request skipped — %s", self._auth_unavailable_reason()) return [] tracks = [] try: limit = 50 # Maximum allowed by Spotify API offset = 0 total_fetched = 0 while True: results = self.sp.current_user_saved_tracks(limit=limit, offset=offset) if not results or 'items' not in results: break batch_count = 0 for item in results['items']: if item['track'] and item['track']['id']: track = Track.from_spotify_track(item['track']) tracks.append(track) batch_count += 1 total_fetched += batch_count logger.info(f"Retrieved {batch_count} saved tracks in batch (offset {offset}), total: {total_fetched}") # Check if we've fetched all saved tracks if len(results['items']) < limit or not results.get('next'): break offset += limit logger.info(f"Retrieved {len(tracks)} total saved tracks") return tracks except Exception as e: logger.error(f"Error fetching saved tracks: {e}") return [] @rate_limited def get_saved_albums(self, since_timestamp=None) -> list: """Fetch user's saved albums from Spotify library. Args: since_timestamp: Optional ISO timestamp string. If provided, stops fetching when reaching albums saved before this time (incremental sync). Returns: List of dicts with album metadata ready for DB upsert. """ if not self.is_spotify_authenticated(): logger.error("Spotify request skipped — %s", self._auth_unavailable_reason()) return [] albums = [] try: limit = 50 # Maximum allowed by Spotify API offset = 0 total_fetched = 0 while True: results = self.sp.current_user_saved_albums(limit=limit, offset=offset) if not results or 'items' not in results: break batch_count = 0 stop_fetching = False for item in results['items']: album_data = item.get('album') added_at = item.get('added_at', '') if not album_data or not album_data.get('id'): continue # Incremental sync: stop when we hit albums saved before last sync if since_timestamp and added_at and added_at < since_timestamp: stop_fetching = True break # Extract primary artist artists = album_data.get('artists', []) artist_name = artists[0]['name'] if artists else 'Unknown Artist' artist_id = artists[0].get('id', '') if artists else '' # Get best image images = album_data.get('images', []) image_url = images[0]['url'] if images else None albums.append({ 'spotify_album_id': album_data['id'], 'album_name': album_data.get('name', ''), 'artist_name': artist_name, 'artist_id': artist_id, 'release_date': album_data.get('release_date', ''), 'total_tracks': album_data.get('total_tracks', 0), 'album_type': album_data.get('album_type', 'album'), 'image_url': image_url, 'date_saved': added_at, }) batch_count += 1 total_fetched += batch_count logger.info(f"Retrieved {batch_count} saved albums in batch (offset {offset}), total: {total_fetched}") if stop_fetching: logger.info(f"Incremental sync: reached albums saved before {since_timestamp}, stopping") break # Check if we've fetched all saved albums if len(results['items']) < limit or not results.get('next'): break offset += limit logger.info(f"Retrieved {len(albums)} total saved albums from Spotify library") return albums except Exception as e: logger.error(f"Error fetching saved albums: {e}") return [] def _get_playlist_items_page(self, playlist_id: str, limit: int = 100, offset: int = 0) -> dict: """Fetch playlist items using the /items endpoint (Feb 2026 Spotify API migration). Spotipy's playlist_items() still uses the deprecated /tracks endpoint internally, which returns 403 for Development Mode apps after the Feb 2026 API changes. Tries the new /items endpoint first, falls back to spotipy's /tracks for Extended Quota Mode apps where /items may not be available yet. """ plid = self.sp._get_id("playlist", playlist_id) try: return self.sp._get( f"playlists/{plid}/items", limit=limit, offset=offset, additional_types="track,episode" ) except spotipy.SpotifyException as e: if e.http_status in (403, 404): # /items not available — fall back to old /tracks endpoint return self.sp.playlist_items(playlist_id, limit=limit, offset=offset) raise @rate_limited def _get_playlist_tracks(self, playlist_id: str) -> List[Track]: if not self.is_spotify_authenticated(): return [] tracks = [] try: results = self._get_playlist_items_page(playlist_id, limit=100) while results: for item in results['items']: # Handle both old API ('track') and new Feb 2026 API ('item') field names track_data = item.get('track') or item.get('item') if track_data and track_data.get('id'): track = Track.from_spotify_track(track_data) tracks.append(track) if results['next']: with _api_call_lock: elapsed = time.time() - _last_api_call_time _pi = _get_min_api_interval() if elapsed < _pi: time.sleep(_pi - elapsed) globals()['_last_api_call_time'] = time.time() from core.api_call_tracker import api_call_tracker api_call_tracker.record_call('spotify', endpoint='get_playlist_tracks_page') results = self.sp.next(results) else: results = None return tracks except Exception as e: logger.error(f"Error fetching playlist tracks: {e}") return [] @rate_limited def get_playlist_by_id(self, playlist_id: str) -> Optional[Playlist]: if not self.is_spotify_authenticated(): return None # "Liked Songs" is virtual — no playlist URI exists for it, so the # playlist endpoint 400s ("Unsupported URL / URI"). Serve it from the # saved-tracks endpoint instead, so mirrored playlists / anything that # re-resolves by stored id can refresh it like a normal playlist. if playlist_id in (LIKED_SONGS_PLAYLIST_ID, 'liked-songs'): return self._liked_songs_as_playlist() try: playlist_data = self.sp.playlist(playlist_id) tracks = self._get_playlist_tracks(playlist_id) return Playlist.from_spotify_playlist(playlist_data, tracks) except Exception as e: logger.error(f"Error fetching playlist {playlist_id}: {e}") return None def _liked_songs_as_playlist(self) -> Optional[Playlist]: """Build a ``Playlist`` for the virtual Liked Songs collection from the saved-tracks endpoint (the only API that serves it).""" try: tracks = self.get_saved_tracks() if not tracks: # get_saved_tracks swallows fetch errors into [] — indistinguishable # from "no likes". The virtual playlist is only offered when likes # exist, so treat empty as a FAILED refresh (None) rather than hand # the sync a valid-looking empty playlist it might mirror by # clearing the server-side copy. logger.error("Liked Songs resolve: saved-tracks fetch returned nothing — treating as failed refresh") return None owner = 'You' try: info = self.get_user_info() if info and info.get('display_name'): owner = info['display_name'] except Exception: # noqa: S110 — owner label is cosmetic; default stands pass return Playlist( id=LIKED_SONGS_PLAYLIST_ID, name='Liked Songs', description='Your liked songs on Spotify', owner=owner, public=False, collaborative=False, tracks=tracks, total_tracks=len(tracks), ) except Exception as e: logger.error(f"Error building Liked Songs playlist: {e}") return None @rate_limited def get_followed_artists(self) -> list: """Fetch all artists the user follows on Spotify. Returns list of dicts with id, name, image_url, genres. Requires user-follow-read scope — returns empty list on 403.""" if not self.is_spotify_authenticated(): return [] try: artists = [] after = None while True: results = self.sp.current_user_followed_artists(limit=50, after=after) if not results or 'artists' not in results: break items = results['artists'].get('items', []) if not items: break for a in items: image_url = a['images'][0]['url'] if a.get('images') else None artists.append({ 'spotify_id': a['id'], 'name': a['name'], 'image_url': image_url, 'genres': a.get('genres', []), }) # Cursor-based pagination cursors = results['artists'].get('cursors', {}) after = cursors.get('after') if not after: break # Throttle pagination _pi = _get_min_api_interval() with _api_call_lock: elapsed = time.time() - _last_api_call_time if elapsed < _pi: time.sleep(_pi - elapsed) globals()['_last_api_call_time'] = time.time() from core.api_call_tracker import api_call_tracker api_call_tracker.record_call('spotify', endpoint='get_followed_artists_page') logger.info(f"Retrieved {len(artists)} followed artists from Spotify") return artists except Exception as e: if '403' in str(e) or 'Forbidden' in str(e): logger.warning("Spotify user-follow-read scope not granted — re-authorize to see followed artists") return [] _detect_and_set_rate_limit(e, 'get_followed_artists') logger.error(f"Error fetching followed artists: {e}") return [] @rate_limited def search_tracks(self, query: str, limit: int = 10, allow_fallback: bool = True) -> List[Track]: """Search for tracks. When allow_fallback is True, falls back to the configured metadata source if Spotify is unavailable or returns an error. """ cache = get_metadata_cache() effective_limit = min(limit, 50) # Spotify API max is 50 # Check Spotify cache first so cached data remains usable even when # Spotify is temporarily unavailable or rate limited. cached_results = cache.get_search_results('spotify', 'track', query, effective_limit) if cached_results is not None: tracks = [] for raw in cached_results: try: tracks.append(Track.from_spotify_track(raw)) except Exception as e: logger.debug("Track.from_spotify_track cache parse: %s", e) if tracks: return tracks # Skip the official API when the no-creds free source should serve this # (no-auth / rate-limited — where auth is already False — plus the # budget-bridge and the worker's prefer-free opt-in, where auth is True # but we deliberately defer to free). The free branch below then runs. use_spotify = self.is_spotify_authenticated() and not self._free_active() if use_spotify: try: results = self.sp.search(q=query, type='track', limit=effective_limit) tracks = [] raw_items = results['tracks']['items'] for track_data in raw_items: track = Track.from_spotify_track(track_data) tracks.append(track) # Cache individual tracks + search mapping entries = [(td.get('id'), td) for td in raw_items if td.get('id')] if entries: cache.store_entities_bulk('spotify', 'track', entries) cache.store_search_results('spotify', 'track', query, effective_limit, [td.get('id') for td in raw_items if td.get('id')]) return tracks except Exception as e: _detect_and_set_rate_limit(e, 'search_tracks') logger.error(f"Error searching tracks via Spotify: {e}") # Fall through to fallback # No-creds Spotify (SpotipyFree) before the iTunes/Deezer fallback — # only when official Spotify is unavailable (no auth / rate-limited). if allow_fallback and self._free_active(): try: objs = [Track.from_spotify_track(t) for t in self._free_meta.search_tracks(query, effective_limit)] if objs: return objs except Exception as e: logger.debug("SpotipyFree track search failed: %s", e) # Fallback (iTunes or Deezer — configured in settings) if allow_fallback: logger.debug(f"Using {self._fallback_source} fallback for track search: {query}") return self._fallback.search_tracks(query, limit) return [] @rate_limited def search_artists(self, query: str, limit: int = 10, allow_fallback: bool = True) -> List[Artist]: """Search for artists. When allow_fallback is True, falls back to the configured metadata source if Spotify is unavailable or returns an error. """ cache = get_metadata_cache() # Check Spotify cache first so cached data remains usable even when # Spotify is temporarily unavailable or rate limited. cached_results = cache.get_search_results('spotify', 'artist', query, min(limit, 10)) if cached_results is not None: artists = [] for raw in cached_results: try: artists.append(Artist.from_spotify_artist(raw)) except Exception as e: logger.debug("Artist.from_spotify_artist cache parse: %s", e) if artists: query_lower = query.lower().strip() artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1)) return artists # Skip the official API when the no-creds free source should serve this # (no-auth / rate-limited — where auth is already False — plus the # budget-bridge and the worker's prefer-free opt-in, where auth is True # but we deliberately defer to free). The free branch below then runs. use_spotify = self.is_spotify_authenticated() and not self._free_active() if use_spotify: try: search_query = f'artist:{query}' if len(query.strip()) <= 4 else query results = self.sp.search(q=search_query, type='artist', limit=min(limit, 10)) artists = [] raw_items = results['artists']['items'] for artist_data in raw_items: artist = Artist.from_spotify_artist(artist_data) artists.append(artist) # Cache individual artists + search mapping entries = [(ad.get('id'), ad) for ad in raw_items if ad.get('id')] if entries: cache.store_entities_bulk('spotify', 'artist', entries) cache.store_search_results('spotify', 'artist', query, min(limit, 10), [ad.get('id') for ad in raw_items if ad.get('id')]) # Re-rank: boost exact name matches to the top query_lower = query.lower().strip() artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1)) return artists except Exception as e: _detect_and_set_rate_limit(e, 'search_artists') logger.error(f"Error searching artists via Spotify: {e}") # Fall through to iTunes fallback # No-creds Spotify (SpotipyFree): keep Spotify catalog/matching when # official Spotify can't serve us (no auth / rate-limited), before the # iTunes/Deezer fallback. Gated by _free_active() so it never runs while # auth is healthy. if allow_fallback and self._free_active(): try: objs = [Artist.from_spotify_artist(a) for a in self._free_meta.search_artists(query, limit)] if objs: query_lower = query.lower().strip() objs.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1)) return objs except Exception as e: logger.debug("SpotipyFree artist search failed: %s", e) # Fallback (iTunes or Deezer) if allow_fallback: logger.debug(f"Using {self._fallback_source} fallback for artist search: {query}") artists = self._fallback.search_artists(query, limit) query_lower = query.lower().strip() artists.sort(key=lambda a: (0 if a.name.lower().strip() == query_lower else 1)) return artists return [] @rate_limited def search_albums(self, query: str, limit: int = 10, allow_fallback: bool = True, artist: str = None, album: str = None) -> List[Album]: """Search for albums. When allow_fallback is True, falls back to the configured metadata source if Spotify is unavailable or returns an error. ``artist`` + ``album`` (the names, passed separately) enable the no-creds Spotify Free path: SpotipyFree has no album-name search, so when Free is active it resolves the album via the artist's discography. Callers without that context (a bare query) skip the Free album path. """ cache = get_metadata_cache() # Check Spotify cache first so cached data remains usable even when # Spotify is temporarily unavailable or rate limited. cached_results = cache.get_search_results('spotify', 'album', query, min(limit, 10)) if cached_results is not None: albums = [] for raw in cached_results: try: albums.append(Album.from_spotify_album(raw)) except Exception as e: logger.debug("Album.from_spotify_album cache parse: %s", e) if albums: return albums # Skip the official API when the no-creds free source should serve this # (no-auth / rate-limited — where auth is already False — plus the # budget-bridge and the worker's prefer-free opt-in, where auth is True # but we deliberately defer to free). The free branch below then runs. use_spotify = self.is_spotify_authenticated() and not self._free_active() if use_spotify: try: results = self.sp.search(q=query, type='album', limit=min(limit, 10)) albums = [] raw_items = results['albums']['items'] for album_data in raw_items: album = Album.from_spotify_album(album_data) albums.append(album) # Cache individual albums + search mapping (skip if full data already cached) entries = [(ad.get('id'), ad) for ad in raw_items if ad.get('id')] if entries: cache.store_entities_bulk('spotify', 'album', entries, skip_if_exists=True) cache.store_search_results('spotify', 'album', query, min(limit, 10), [ad.get('id') for ad in raw_items if ad.get('id')]) return albums except Exception as e: _detect_and_set_rate_limit(e, 'search_albums') logger.error(f"Error searching albums via Spotify: {e}") # Fall through to free / iTunes fallback # No-creds Spotify (SpotipyFree): keep Spotify catalog/matching when # official Spotify can't serve us (no auth / rate-limited / budget spent), # before the iTunes/Deezer fallback. Albums have no name-search upstream, # so resolve via the artist's discography — needs artist + album names. # Gated by _free_active() so it never runs while auth is healthy. if allow_fallback and self._free_active() and artist and album: try: objs = [Album.from_spotify_album(a) for a in self._free_meta.search_albums_via_artist(artist, album, min(limit, 10))] if objs: return objs except Exception as e: logger.debug("SpotipyFree album search failed: %s", e) # Fallback (iTunes or Deezer) if allow_fallback: logger.debug(f"Using {self._fallback_source} fallback for album search: {query}") return self._fallback.search_albums(query, limit) return [] @rate_limited def get_track_details(self, track_id: str, allow_fallback: bool = True) -> Optional[Dict[str, Any]]: """Get detailed track information. When allow_fallback is True, falls back to the configured metadata source for non-Spotify IDs or Spotify failure. """ # Check cache — we store raw track_data, reconstruct enhanced on hit cache = get_metadata_cache() fallback_src = self._fallback_source source = fallback_src if self._is_itunes_id(track_id) else 'spotify' cached = cache.get_entity(source, 'track', track_id) if cached: if source == 'spotify': # Validate cache has full track data (not simplified from get_album_tracks) if 'album' in cached: return self._build_enhanced_track(cached) # Simplified track cached by get_album_tracks — treat as cache miss logger.debug(f"Cache hit for track {track_id} lacks album data, fetching full data") elif allow_fallback: # Fallback cache hit — delegate to fallback client which reconstructs enhanced format return self._fallback.get_track_details(track_id) if self.is_spotify_authenticated() and not self._free_active(): try: track_data = self.sp.track(track_id) # Enhance with additional useful metadata for our purposes if track_data: # Cache the raw Spotify response cache.store_entity('spotify', 'track', track_id, track_data) return self._build_enhanced_track(track_data) return track_data except Exception as e: _detect_and_set_rate_limit(e, 'get_track_details') logger.error(f"Error fetching track details via Spotify: {e}") # Fall through to iTunes fallback # No-creds Spotify (SpotipyFree) for a real Spotify track id when # official Spotify is unavailable (no auth / rate-limited). if allow_fallback and self._free_active() and not self._is_itunes_id(track_id): free = self._free_meta.get_track_details(track_id) if free: return free # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(track_id): logger.debug(f"Using {fallback_src} fallback for track details: {track_id}") result = self._fallback.get_track_details(track_id) return result else: logger.debug(f"Cannot use fallback for Spotify track ID: {track_id}") return None @staticmethod def _build_enhanced_track(track_data: dict) -> dict: """Build enhanced track dict from raw Spotify track data.""" return { 'id': track_data['id'], 'name': track_data['name'], 'track_number': track_data['track_number'], 'disc_number': track_data['disc_number'], 'duration_ms': track_data['duration_ms'], 'explicit': track_data['explicit'], 'artists': [artist['name'] for artist in track_data['artists']], 'primary_artist': track_data['artists'][0]['name'] if track_data['artists'] else None, 'album': { 'id': track_data['album']['id'], 'name': track_data['album']['name'], 'total_tracks': track_data['album']['total_tracks'], 'release_date': track_data['album']['release_date'], 'album_type': track_data['album']['album_type'], 'artists': [artist['name'] for artist in track_data['album']['artists']] }, 'is_album_track': track_data['album']['total_tracks'] > 1, 'raw_data': track_data } @rate_limited def get_track_features(self, track_id: str) -> Optional[Dict[str, Any]]: # Check cache — use entity_id with '_features' suffix cache = get_metadata_cache() cache_key = f"{track_id}_features" cached = cache.get_entity('spotify', 'track', cache_key) if cached: return cached if not self.is_spotify_authenticated(): return None try: features = self.sp.audio_features(track_id) result = features[0] if features else None if result: cache.store_entity('spotify', 'track', cache_key, result) return result except Exception as e: logger.error(f"Error fetching track features: {e}") return None @rate_limited def get_album(self, album_id: str, allow_fallback: bool = True) -> Optional[Dict[str, Any]]: """Get album information. When allow_fallback is True, falls back to the configured metadata source for non-Spotify IDs or Spotify failure. """ # Check cache first cache = get_metadata_cache() fallback_src = self._fallback_source source = fallback_src if self._is_itunes_id(album_id) else 'spotify' cached = cache.get_entity(source, 'album', album_id) if cached: if source == 'spotify': # Validate cache has full album data (not simplified from artist_albums) if 'tracks' in cached: return cached # Simplified album cached by get_artist_albums — treat as cache miss logger.debug(f"Cache hit for album {album_id} lacks tracks, fetching full data") elif allow_fallback: # Fallback cache hit — delegate to fallback client return self._fallback.get_album(album_id) if self.is_spotify_authenticated() and not self._free_active(): try: album_data = self.sp.album(album_id) if album_data: cache.store_entity('spotify', 'album', album_id, album_data) return album_data except Exception as e: _detect_and_set_rate_limit(e, 'get_album') logger.error(f"Error fetching album via Spotify: {e}") # Fall through to fallback # No-creds Spotify (SpotipyFree) for a real Spotify album id when # official Spotify is unavailable (no auth / rate-limited). if allow_fallback and self._free_active() and not self._is_itunes_id(album_id): free = self._free_meta.get_album(album_id) if free: return free # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(album_id): logger.debug(f"Using {fallback_src} fallback for album: {album_id}") return self._fallback.get_album(album_id) else: logger.debug(f"Cannot use fallback for Spotify album ID: {album_id}") return None @rate_limited def get_album_tracks(self, album_id: str, allow_fallback: bool = True) -> Optional[Dict[str, Any]]: """Get album tracks. When allow_fallback is True, falls back to the configured metadata source for non-Spotify IDs or Spotify failure. """ # Cache key uses album_id with '_tracks' suffix to differentiate from album metadata cache = get_metadata_cache() fallback_src = self._fallback_source source = fallback_src if self._is_itunes_id(album_id) else 'spotify' cache_key = f"{album_id}_tracks" cached = cache.get_entity(source, 'album', cache_key) if cached: return cached if self.is_spotify_authenticated() and not self._free_active(): try: # Get first page of tracks first_page = self.sp.album_tracks(album_id) if not first_page or 'items' not in first_page: return None # Collect all tracks starting with first page all_tracks = first_page['items'][:] # Fetch remaining pages if they exist — throttle pagination next_page = first_page while next_page.get('next'): with _api_call_lock: elapsed = time.time() - _last_api_call_time _pi = _get_min_api_interval() if elapsed < _pi: time.sleep(_pi - elapsed) globals()['_last_api_call_time'] = time.time() from core.api_call_tracker import api_call_tracker api_call_tracker.record_call('spotify', endpoint='get_album_tracks_page') next_page = self.sp.next(next_page) if next_page and 'items' in next_page: all_tracks.extend(next_page['items']) # Log success logger.info(f"Retrieved {len(all_tracks)} tracks for album {album_id}") # Return structure with all tracks result = first_page.copy() result['items'] = all_tracks result['next'] = None # No more pages result['limit'] = len(all_tracks) # Update to reflect all tracks fetched # Cache the aggregated result cache.store_entity('spotify', 'album', cache_key, result) # Also cache individual tracks opportunistically (skip if full data already cached) track_entries = [] for track in all_tracks: tid = track.get('id') if tid: track_entries.append((tid, track)) if track_entries: cache.store_entities_bulk('spotify', 'track', track_entries, skip_if_exists=True) return result except Exception as e: _detect_and_set_rate_limit(e, 'get_album_tracks') logger.error(f"Error fetching album tracks via Spotify: {e}") # Fall through to iTunes fallback # No-creds Spotify (SpotipyFree) for a real Spotify album id when # official Spotify is unavailable (no auth / rate-limited). if allow_fallback and self._free_active() and not self._is_itunes_id(album_id): free = self._free_meta.get_album_tracks(album_id) if free: return free # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(album_id): logger.debug(f"Using {fallback_src} fallback for album tracks: {album_id}") result = self._fallback.get_album_tracks(album_id) return result else: logger.debug(f"Cannot use fallback for Spotify album ID: {album_id}") return None @rate_limited def get_artist_albums(self, artist_id: str, album_type: str = 'album,single', limit: int = 10, skip_cache: bool = False, max_pages: int = 0, allow_fallback: bool = True) -> List[Album]: """Get albums by artist ID. When allow_fallback is True, falls back to iTunes/Deezer if Spotify is not authenticated or errors. Set skip_cache=True for watchlist scans that need fresh data to detect new releases. Set max_pages to limit pagination (0 = fetch all). Spotify returns newest first, so max_pages=1 is sufficient for new release detection.""" cache = get_metadata_cache() fallback_src = self._fallback_source source = fallback_src if self._is_itunes_id(artist_id) else 'spotify' # Check cache first (unless caller needs fresh data) if not skip_cache: cached_items = get_cached_artist_album_items( cache, source, artist_id, album_type=album_type, limit=limit, include_limit=False, ) if cached_items: try: return [Album.from_spotify_album(ad) for ad in cached_items] except Exception as e: logger.debug("artist albums cache reuse: %s", e) if self.is_spotify_authenticated() and not self._free_active(): try: albums = [] raw_items = [] truncated = False # did we stop while more pages existed? # Spotify caps artist_albums at 10 per page results = self.sp.artist_albums(artist_id, album_type=album_type, limit=min(limit, 10)) pages_fetched = 1 while results: for album_data in results['items']: album = Album.from_spotify_album(album_data) albums.append(album) raw_items.append(album_data) # Stop if we've hit the page limit (0 = unlimited) if max_pages and pages_fetched >= max_pages: truncated = bool(results.get('next')) break # Get next batch if available — throttle pagination to respect rate limits if results['next']: # Enforce same rate limit as decorated calls with _api_call_lock: elapsed = time.time() - _last_api_call_time _pi = _get_min_api_interval() if elapsed < _pi: time.sleep(_pi - elapsed) globals()['_last_api_call_time'] = time.time() from core.api_call_tracker import api_call_tracker api_call_tracker.record_call('spotify', endpoint='get_artist_albums_page') results = self.sp.next(results) pages_fetched += 1 else: results = None logger.info(f"Retrieved {len(albums)} albums for artist {artist_id}" + (f" (page limit: {max_pages})" if max_pages else "")) # Cache the full artist albums result (wrapped in dict for cache compatibility) # Only cache COMPLETE discographies. The cache key carries no # limit/page info, so a partial probe (the watchlist's # new-release check: limit=5, max_pages=1) stored here used to # POISON the slot — the artist detail page then showed only # the 5-10 newest releases for every watchlist artist until # the 30-day TTL expired ("Taylor Swift has 8 albums, nothing # before 2022"). Individual albums are still cached — they're # complete entities regardless of how many pages we walked. if raw_items: if not truncated: store_artist_album_items( cache, 'spotify', artist_id, raw_items, album_type=album_type, limit=limit, include_limit=False, ) # Also cache individual albums opportunistically entries = [(ad.get('id'), ad) for ad in raw_items if ad.get('id')] if entries: cache.store_entities_bulk('spotify', 'album', entries, skip_if_exists=True) return albums except Exception as e: _detect_and_set_rate_limit(e, 'get_artist_albums') logger.error(f"Error fetching artist albums via Spotify: {e}") # Fall through to iTunes fallback # No-creds Spotify (SpotipyFree) for a real Spotify artist id when # official Spotify is unavailable (no auth / rate-limited). The free # discography is already album-shaped — project to Album dataclasses. if allow_fallback and self._free_active() and not self._is_itunes_id(artist_id): try: free = [Album.from_spotify_album(a) for a in self._free_meta.get_artist_albums_list(artist_id, limit)] if free: return free except Exception as e: logger.debug("SpotipyFree artist albums failed: %s", e) # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(artist_id): logger.debug(f"Using {fallback_src} fallback for artist albums: {artist_id}") return self._fallback.get_artist_albums(artist_id, album_type, limit) else: logger.debug(f"Cannot use fallback for Spotify artist ID: {artist_id}") return [] @rate_limited def get_user_info(self) -> Optional[Dict[str, Any]]: if not self.is_spotify_authenticated(): return None try: return self.sp.current_user() except Exception as e: logger.error(f"Error fetching user info: {e}") return None @rate_limited def get_artist(self, artist_id: str, allow_fallback: bool = True) -> Optional[Dict[str, Any]]: """ Get full artist details. Args: artist_id: Artist ID (Spotify or fallback source depending on authentication) Returns: Dictionary with artist data including images, genres, popularity """ # Check cache first (works even during rate limit bans) cache = get_metadata_cache() fallback_src = self._fallback_source source = fallback_src if self._is_itunes_id(artist_id) else 'spotify' cached = cache.get_entity(source, 'artist', artist_id) if cached: if source == 'spotify': return cached # Spotify raw format is the expected format if allow_fallback: # Fallback cache hit — delegate to fallback client which reconstructs Spotify-compatible format return self._fallback.get_artist(artist_id) return None if self.is_spotify_authenticated() and not self._free_active(): try: result = self.sp.artist(artist_id) if result: cache.store_entity('spotify', 'artist', artist_id, result) return result except Exception as e: _detect_and_set_rate_limit(e, 'get_artist') logger.error(f"Error fetching artist via Spotify: {e}") # Fall through to iTunes fallback # No-creds Spotify (SpotipyFree) for a real Spotify artist id when # official Spotify is unavailable (no auth / rate-limited). if allow_fallback and self._free_active() and not self._is_itunes_id(artist_id): free = self._free_meta.get_artist(artist_id) if free: return free # Fallback - only if ID is numeric (non-Spotify format) if allow_fallback and self._is_itunes_id(artist_id): logger.debug(f"Using {fallback_src} fallback for artist: {artist_id}") return self._fallback.get_artist(artist_id) else: logger.debug(f"Cannot use fallback for Spotify artist ID: {artist_id}") return None @rate_limited def get_artist_top_tracks(self, artist_id: str, country: str = 'US', limit: int = 10) -> List[Dict[str, Any]]: """Return up to 10 top tracks for an artist (Spotify caps the response at 10). Spotify's `artist_top_tracks` endpoint always returns ~10 tracks for the given market regardless of any limit param. The `limit` argument here is honored as a UI-side trim only — it never reduces the number of API calls. Tracks come back in Spotify's standard track-object shape (id, name, artists, album, ...) so the rest of the pipeline can consume them unchanged. """ if not artist_id: return [] if not self.is_spotify_authenticated(): return [] try: result = self.sp.artist_top_tracks(artist_id, country=country) except Exception as e: _detect_and_set_rate_limit(e, 'get_artist_top_tracks') logger.warning(f"Spotify artist_top_tracks failed for {artist_id}: {e}") return [] if not result: return [] tracks = result.get('tracks', []) or [] return tracks[:max(1, int(limit or 10))] @rate_limited def get_artists_batch(self, artist_ids: List[str]) -> Dict[str, Dict]: """Get multiple artists, using cache where possible, batch API for misses. Returns dict keyed by artist_id → artist data dict.""" if not artist_ids: return {} cache = get_metadata_cache() found, missing = cache.get_entities_batch('spotify', 'artist', artist_ids) if missing and self.is_spotify_authenticated(): try: # Spotify batch endpoint accepts up to 50 IDs for i in range(0, len(missing), 50): chunk = missing[i:i + 50] batch_result = self.sp.artists(chunk) for artist_data in (batch_result or {}).get('artists', []): if artist_data and artist_data.get('id'): aid = artist_data['id'] cache.store_entity('spotify', 'artist', aid, artist_data) found[aid] = artist_data except Exception as e: logger.error(f"Error in batch artist fetch: {e}") return found