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);