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:
parent
c34905997b
commit
5e61a15f7f
5 changed files with 126 additions and 13 deletions
|
|
@ -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)"""
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue