From 5e61a15f7f7b2fbfe52936a876947ca3c4015026 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Wed, 18 Feb 2026 11:40:07 -0800 Subject: [PATCH] Add Spotify disconnect button and cache auth checks Add a UI button to disconnect Spotify and fall back to iTunes/Apple Music without restarting. Cache is_spotify_authenticated() with a 60s TTL to reduce redundant API calls (~46 call sites were each triggering a live sp.current_user() call). Fix status endpoint calling the auth check twice per poll, and ensure both OAuth callback handlers (port 8008 Flask route and port 8888 dedicated server) invalidate the status cache so the UI updates immediately after authentication. --- core/spotify_client.py | 48 ++++++++++++++++++++++++++++++++++++++---- web_server.py | 43 +++++++++++++++++++++++++++++-------- webui/index.html | 3 +++ webui/static/script.js | 34 ++++++++++++++++++++++++++++++ webui/static/style.css | 11 ++++++++++ 5 files changed, 126 insertions(+), 13 deletions(-) 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 "

Spotify Authentication Successful!

You can close this window.

" else: @@ -3097,6 +3100,26 @@ def spotify_callback(): return f"

Spotify Authentication Failed

{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 @@
+
diff --git a/webui/static/script.js b/webui/static/script.js index 87fdf697..610038b9 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -353,6 +353,11 @@ document.addEventListener('DOMContentLoaded', function () { fetchAndUpdateServiceStatus(); setInterval(fetchAndUpdateServiceStatus, 10000); // Every 10 seconds + // Refresh status immediately when user returns to this tab (e.g. after OAuth in new tab) + document.addEventListener('visibilitychange', () => { + if (!document.hidden) fetchAndUpdateServiceStatus(); + }); + // Start always-on download polling (batched, minimal overhead) startGlobalDownloadPolling(); @@ -2443,6 +2448,29 @@ async function authenticateSpotify() { } } +async function disconnectSpotify() { + if (!confirm('Disconnect Spotify? The app will switch to Apple Music/iTunes for metadata.')) { + return; + } + try { + showLoadingOverlay('Disconnecting Spotify...'); + const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); + const data = await response.json(); + if (data.success) { + showToast('Spotify disconnected. Now using Apple Music/iTunes.', 'success'); + // Immediately refresh status to update UI + await fetchAndUpdateServiceStatus(); + } else { + showToast(`Failed to disconnect: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error disconnecting Spotify:', error); + showToast('Failed to disconnect Spotify', 'error'); + } finally { + hideLoadingOverlay(); + } +} + async function authenticateTidal() { try { showLoadingOverlay('Starting Tidal authentication...'); @@ -23657,6 +23685,12 @@ function updateServiceStatus(service, statusData) { // Update global variable for use in discovery modals currentMusicSourceName = sourceName; } + + // Show/hide Spotify disconnect button based on connection state + const disconnectBtn = document.getElementById('spotify-disconnect-btn'); + if (disconnectBtn) { + disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none'; + } } } diff --git a/webui/static/style.css b/webui/static/style.css index 1f0c4139..7b23afe7 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -1707,6 +1707,17 @@ body { box-shadow: 0 6px 16px rgba(29, 185, 84, 0.35); } +.disconnect-button { + background: linear-gradient(135deg, #e53935, #ef5350) !important; + color: #ffffff !important; + box-shadow: 0 4px 12px rgba(229, 57, 53, 0.2) !important; +} + +.disconnect-button:hover { + background: linear-gradient(135deg, #ef5350, #f44336) !important; + box-shadow: 0 6px 16px rgba(229, 57, 53, 0.35) !important; +} + .tidal-title+* .auth-button { background: linear-gradient(135deg, #ff6600, #ff8833); box-shadow: 0 4px 12px rgba(255, 102, 0, 0.2);