diff --git a/core/spotify_client.py b/core/spotify_client.py index f11e21e3..fbaf3ca1 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -27,10 +27,16 @@ _rate_limit_until = 0 # Unix timestamp when the ban expires (0 = not banne _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) # 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 value they sent us. +_POST_BAN_COOLDOWN = 300 # 5 minutes + class SpotifyRateLimitError(Exception): """Raised when Spotify API calls are blocked due to active global rate limit ban.""" @@ -61,15 +67,41 @@ def _set_global_rate_limit(retry_after_seconds, endpoint_name): 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 — clear it + # 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: @@ -90,14 +122,16 @@ def _get_rate_limit_info(): def _clear_rate_limit(): - """Manually clear the global rate limit ban (e.g. after disconnect/reconnect).""" - global _rate_limit_until, _rate_limit_retry_after, _rate_limit_endpoint, _rate_limit_set_at + """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 with _rate_limit_lock: _rate_limit_until = 0 _rate_limit_retry_after = 0 _rate_limit_endpoint = None _rate_limit_set_at = 0 - logger.info("Global rate limit ban cleared") + _rate_limit_ban_ended_at = 0 + logger.info("Global rate limit ban cleared (including post-ban cooldown)") def rate_limited(func): @@ -372,7 +406,8 @@ class SpotifyClient: 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.""" + 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.""" if self.sp is None: return False @@ -382,6 +417,14 @@ class SpotifyClient: if _is_globally_rate_limited(): 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") + 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: @@ -453,7 +496,12 @@ class SpotifyClient: 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: diff --git a/core/spotify_worker.py b/core/spotify_worker.py index e210e15e..9b7be78e 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -97,13 +97,19 @@ class SpotifyWorker: is_idle = is_actually_running and not self.paused and self.stats['pending'] == 0 and self.current_item is None try: - # During rate limit, is_spotify_authenticated() returns False to suppress calls, - # but we're still authenticated — just banned. Report truthfully. + # During rate limit or post-ban cooldown, report as authenticated + # but don't call is_spotify_authenticated() — that would trigger an + # auth probe which resets the rate limit timer. rate_limited = self.client.is_rate_limited() - authenticated = rate_limited or self.client.is_spotify_authenticated() + in_cooldown = self.client.get_post_ban_cooldown_remaining() > 0 + if rate_limited or in_cooldown: + authenticated = True # We're authenticated, just banned/cooling down + else: + authenticated = self.client.is_spotify_authenticated() except Exception: authenticated = False rate_limited = False + in_cooldown = False return { 'enabled': True, @@ -135,6 +141,14 @@ class SpotifyWorker: time.sleep(min(remaining, 60)) # Check again every 60s max continue + # Post-ban cooldown guard — after ban expires, wait before resuming + # to avoid immediately re-triggering the rate limit + cooldown = self.client.get_post_ban_cooldown_remaining() + if cooldown > 0: + logger.debug(f"Post-ban cooldown active ({cooldown}s left), sleeping...") + time.sleep(min(cooldown, 60)) + continue + # Auth guard — don't process anything without Spotify auth if not self.client.is_spotify_authenticated(): # Try reloading config in case user re-authenticated via settings diff --git a/web_server.py b/web_server.py index 840f4d1b..8a421062 100644 --- a/web_server.py +++ b/web_server.py @@ -3529,26 +3529,28 @@ def get_status(): # Test Spotify - with caching to avoid excessive API calls if current_time - _status_cache_timestamps['spotify'] > STATUS_CACHE_TTL: - spotify_start = time.time() - # Auth check first — may detect and set a new rate limit ban via probe - spotify_connected = spotify_client.is_spotify_authenticated() - spotify_response_time = (time.time() - spotify_start) * 1000 - - # Check rate limit AFTER auth (auth probe may have just set the ban) - rate_limit_info = spotify_client.get_rate_limit_info() - - # During rate limit, is_spotify_authenticated() returns False to suppress calls, - # but we still report source as 'spotify' so UI doesn't flip to "Apple Music" - if rate_limit_info: + # Check is_rate_limited() first — this calls _is_globally_rate_limited() which + # records ban-end timestamp if the ban just expired, enabling cooldown detection. + is_rate_limited = spotify_client.is_rate_limited() + rate_limit_info = spotify_client.get_rate_limit_info() if is_rate_limited else None + cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() + + if is_rate_limited or cooldown_remaining > 0: + # During rate limit or post-ban cooldown, skip the auth probe entirely. + # Probing Spotify here would reset the rate limit timer. music_source = 'spotify' + spotify_response_time = 0 else: + spotify_start = time.time() + spotify_connected = spotify_client.is_spotify_authenticated() + spotify_response_time = (time.time() - spotify_start) * 1000 music_source = 'spotify' if spotify_connected else 'itunes' _status_cache['spotify'] = { 'connected': True, # Always true — iTunes fallback is always available 'response_time': round(spotify_response_time, 1), 'source': music_source, - 'rate_limited': rate_limit_info is not None, + 'rate_limited': is_rate_limited, 'rate_limit': rate_limit_info } _status_cache_timestamps['spotify'] = current_time @@ -34849,10 +34851,15 @@ def _build_status_payload(): soulseek_data['source'] = download_mode # Always include fresh rate limit info (it changes over time as ban expires) + # Call is_rate_limited() first to ensure ban-end timestamp is recorded for cooldown spotify_data = dict(_status_cache.get('spotify', {})) - rate_limit_info = spotify_client.get_rate_limit_info() if spotify_client else None - spotify_data['rate_limited'] = rate_limit_info is not None + is_rl = spotify_client.is_rate_limited() if spotify_client else False + rate_limit_info = spotify_client.get_rate_limit_info() if is_rl else None + cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 + spotify_data['rate_limited'] = is_rl spotify_data['rate_limit'] = rate_limit_info + if cooldown_remaining > 0: + spotify_data['post_ban_cooldown'] = cooldown_remaining return { 'spotify': spotify_data,