diff --git a/core/spotify_client.py b/core/spotify_client.py index 1f2a613f..d5dbb464 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -193,6 +193,10 @@ class SpotifyClient: self.sp: Optional[spotipy.Spotify] = None self.user_id: Optional[str] = None self._itunes_client = None # Lazy-loaded iTunes fallback + self._auth_cache_lock = threading.Lock() + self._auth_cached_result: Optional[bool] = None + self._auth_cache_time: float = 0 + self._AUTH_CACHE_TTL = 60 # seconds self._setup_client() def _is_spotify_id(self, id_str: str) -> bool: @@ -219,6 +223,7 @@ class SpotifyClient: def reload_config(self): """Reload configuration and re-initialize client""" + self._invalidate_auth_cache() self._setup_client() def _setup_client(self): @@ -259,18 +264,53 @@ class SpotifyClient: # iTunes fallback is always available 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)""" + """Check if Spotify client is specifically authenticated (not just iTunes fallback). + Results are cached for 60 seconds to avoid excessive API calls.""" if self.sp is None: 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: + return self._auth_cached_result + + # Cache miss — make API call outside the lock try: - # Make a simple API call to verify authentication self.sp.current_user() - return True + result = True except Exception as e: logger.debug(f"Spotify authentication check failed: {e}") - return False + result = False + + with self._auth_cache_lock: + self._auth_cached_result = result + self._auth_cache_time = time.time() + + return result + + def disconnect(self): + """Disconnect Spotify: clear client, delete cache, invalidate auth cache""" + import os + self.sp = None + self.user_id = None + self._invalidate_auth_cache() + + cache_path = 'config/.spotify_cache' + try: + if os.path.exists(cache_path): + os.remove(cache_path) + logger.info("Deleted Spotify cache file") + except Exception as e: + logger.warning(f"Failed to delete Spotify cache: {e}") + + logger.info("Spotify client disconnected") def _ensure_user_id(self) -> bool: """Ensure user_id is loaded (may make API call)""" diff --git a/web_server.py b/web_server.py index 811ef6d8..ef2196eb 100644 --- a/web_server.py +++ b/web_server.py @@ -2040,7 +2040,7 @@ def index(): # Status check caching to reduce unnecessary API calls _status_cache = { - 'spotify': {'connected': False, 'response_time': 0}, + 'spotify': {'connected': False, 'response_time': 0, 'source': 'itunes'}, 'media_server': {'connected': False, 'response_time': 0, 'type': None}, 'soulseek': {'connected': False, 'response_time': 0} } @@ -2064,15 +2064,14 @@ 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() - # Actually validate authentication (makes API call, but cached for 2 min) - spotify_status = spotify_client.is_authenticated() + # Single auth check — is_spotify_authenticated() is cached internally (60s TTL) + spotify_connected = spotify_client.is_spotify_authenticated() spotify_response_time = (time.time() - spotify_start) * 1000 - - # Determine active music source (spotify or itunes) - music_source = 'spotify' if spotify_client.is_spotify_authenticated() else 'itunes' - + + music_source = 'spotify' if spotify_connected else 'itunes' + _status_cache['spotify'] = { - 'connected': spotify_status, + 'connected': True, # Always true — iTunes fallback is always available 'response_time': round(spotify_response_time, 1), 'source': music_source } @@ -2501,6 +2500,7 @@ def test_connection_endpoint(): current_time = time.time() if service == 'spotify': _status_cache['spotify']['connected'] = True + _status_cache['spotify']['source'] = 'spotify' if spotify_client.is_spotify_authenticated() else 'itunes' _status_cache_timestamps['spotify'] = current_time print("✅ Updated Spotify status cache after successful test") elif service in ['plex', 'jellyfin', 'navidrome']: @@ -2550,6 +2550,7 @@ def test_dashboard_connection_endpoint(): current_time = time.time() if service == 'spotify': _status_cache['spotify']['connected'] = True + _status_cache['spotify']['source'] = 'spotify' if spotify_client.is_spotify_authenticated() else 'itunes' _status_cache_timestamps['spotify'] = current_time print("✅ Updated Spotify status cache after successful dashboard test") elif service in ['plex', 'jellyfin', 'navidrome']: @@ -3085,6 +3086,8 @@ def spotify_callback(): if token_info: spotify_client = SpotifyClient() if spotify_client.is_authenticated(): + # Invalidate status cache so next poll picks up the new connection + _status_cache_timestamps['spotify'] = 0 add_activity_item("✅", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") return "
You can close this window.
" else: @@ -3097,6 +3100,26 @@ def spotify_callback(): return f"{str(e)}
", 400 +@app.route('/api/spotify/disconnect', methods=['POST']) +def spotify_disconnect(): + """Disconnect Spotify and fall back to iTunes/Apple Music""" + global spotify_client + try: + spotify_client.disconnect() + # Immediately update status cache so UI reflects the change + _status_cache['spotify'] = { + 'connected': True, # iTunes fallback is always available + 'response_time': 0, + 'source': 'itunes' + } + _status_cache_timestamps['spotify'] = time.time() + add_activity_item("🔌", "Spotify Disconnected", "Switched to Apple Music/iTunes metadata source", "Now") + return jsonify({'success': True, 'message': 'Spotify disconnected. Now using Apple Music/iTunes.'}) + except Exception as e: + logger.error(f"Error disconnecting Spotify: {e}") + return jsonify({'success': False, 'error': str(e)}), 500 + + @app.route('/tidal/callback') def tidal_callback(): """ @@ -25089,8 +25112,10 @@ def start_oauth_callback_servers(): # Reinitialize the global client with new tokens global spotify_client spotify_client = SpotifyClient() - + if spotify_client.is_authenticated(): + # Invalidate status cache so next poll picks up the new connection + _status_cache_timestamps['spotify'] = 0 add_activity_item("✅", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") self.send_response(200) self.send_header('Content-type', 'text/html') diff --git a/webui/index.html b/webui/index.html index c9a1fa42..535b277f 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2575,6 +2575,9 @@