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.
This commit is contained in:
Broque Thomas 2026-02-18 11:40:07 -08:00
parent c34905997b
commit 5e61a15f7f
5 changed files with 126 additions and 13 deletions

View file

@ -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)"""

View file

@ -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 "<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>"
else:
@ -3097,6 +3100,26 @@ def spotify_callback():
return f"<h1>Spotify Authentication Failed</h1><p>{str(e)}</p>", 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')

View file

@ -2575,6 +2575,9 @@
<div class="form-actions">
<button class="auth-button" onclick="authenticateSpotify()">🔐
Authenticate</button>
<button class="auth-button disconnect-button" id="spotify-disconnect-btn"
onclick="disconnectSpotify()" style="display: none;">🔌
Disconnect</button>
</div>
</div>

View file

@ -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';
}
}
}

View file

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