From 5ef83cea72bc86599df46ee8f1dd46effe2672aa Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 10:03:28 +0300 Subject: [PATCH 01/11] Stop watchlist countdown refetch loop - Avoid refetching /api/watchlist/count every second when no auto-run is scheduled. - Keep the timer active only while a next run exists; otherwise leave the label static. --- webui/static/api-monitor.js | 34 +++++++++++++++++++++++----------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index c900d824..01e27f0a 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -1837,6 +1837,19 @@ function startWatchlistCountdownTimer(initialSeconds) { } let remainingSeconds = initialSeconds; + const timerElement = document.getElementById('watchlist-next-auto-timer'); + const updateTimerText = (seconds) => { + if (!timerElement) return; + const countdownText = seconds > 0 ? formatCountdownTime(seconds) : ''; + timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`; + }; + + // If there is no scheduled next run, don't keep polling the endpoint every second. + if (remainingSeconds <= 0) { + updateTimerText(0); + watchlistCountdownInterval = null; + return; + } watchlistCountdownInterval = setInterval(async () => { remainingSeconds--; @@ -1846,23 +1859,23 @@ function startWatchlistCountdownTimer(initialSeconds) { try { const response = await fetch('/api/watchlist/count'); const data = await response.json(); - remainingSeconds = data.next_run_in_seconds || 0; + const nextRunSeconds = data.next_run_in_seconds || 0; - const timerElement = document.getElementById('watchlist-next-auto-timer'); - if (timerElement) { - const countdownText = formatCountdownTime(remainingSeconds); - timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`; + if (nextRunSeconds > 0) { + remainingSeconds = nextRunSeconds; + updateTimerText(remainingSeconds); + } else { + // No scheduled auto-run; stop polling until the page is refreshed + clearInterval(watchlistCountdownInterval); + watchlistCountdownInterval = null; + updateTimerText(0); } } catch (error) { console.debug('Error updating watchlist countdown:', error); } } else { // Update the display - const timerElement = document.getElementById('watchlist-next-auto-timer'); - if (timerElement) { - const countdownText = formatCountdownTime(remainingSeconds); - timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`; - } + updateTimerText(remainingSeconds); } }, 1000); // Update every second } @@ -3791,4 +3804,3 @@ function cleanupSyncPageLogs() { // --- Global Cleanup on Page Unload --- // Note: Automatic wishlist processing now runs server-side and continues even when browser is closed // =============================== - From 1d9d399a2f79a8e59877195c2706686136507820 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 10:17:05 +0300 Subject: [PATCH 02/11] Fix dashboard metadata source testing - Point the dashboard Test Connection button at the active metadata source instead of hardcoded Spotify. - Populate the response line from the current status payload so the card no longer stays at Response: --. - Keep the existing Spotify-specific auth handling when Spotify is the configured source. --- webui/index.html | 2 +- webui/static/core.js | 4 ++++ webui/static/shared-helpers.js | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/webui/index.html b/webui/index.html index 1b9c2694..4e5d3afc 100644 --- a/webui/index.html +++ b/webui/index.html @@ -626,7 +626,7 @@

Response: --

diff --git a/webui/static/core.js b/webui/static/core.js index 3a23cb53..4e3b6ad8 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -885,4 +885,8 @@ const API = { let _lastServiceStatus = null; let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden) +function getActiveMetadataSource() { + return _lastServiceStatus?.spotify?.source || 'spotify'; +} + // =============================== diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 6fed9d88..29b2a1fa 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -3323,6 +3323,20 @@ function updateServiceStatus(service, statusData) { } syncPrimaryMetadataSourceAvailability(statusData); + + const responseTimeElement = document.getElementById('spotify-response-time'); + if (responseTimeElement) { + const responseTime = statusData.response_time; + responseTimeElement.textContent = responseTime !== undefined && responseTime !== null + ? `Response: ${responseTime}ms` + : 'Response: --'; + } + + const testButton = document.querySelector('#spotify-service-card .service-card-button'); + if (testButton) { + const source = statusData.source || 'spotify'; + testButton.setAttribute('onclick', `testDashboardConnection('${source}')`); + } } // Update download source title on dashboard card From 36267618a381f26b109f248f7d29acd53a3d6441 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 10:42:33 +0300 Subject: [PATCH 03/11] Rename status cache to metadata_source Expose the primary metadata provider status under a generic cache key and update the websocket fixture plus frontend readers to match. --- tests/conftest.py | 4 +- tests/test_websocket_infrastructure.py | 10 ++-- web_server.py | 72 +++++++++++++------------- webui/static/core.js | 22 ++++---- webui/static/helper.js | 4 +- webui/static/settings.js | 14 ++--- webui/static/shared-helpers.js | 14 ++--- webui/static/wishlist-tools.js | 6 +-- 8 files changed, 73 insertions(+), 73 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 03b5ec5f..4adc2f96 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ from flask_socketio import SocketIO, join_room, leave_room # --------------------------------------------------------------------------- _DEFAULT_STATUS_CACHE = { - 'spotify': {'connected': True, 'authenticated': True, 'response_time': 12.5, 'source': 'spotify'}, + 'metadata_source': {'connected': True, 'authenticated': True, 'response_time': 12.5, 'source': 'spotify'}, 'media_server': {'connected': True, 'response_time': 8.1, 'type': 'plex'}, 'soulseek': {'connected': True, 'response_time': 5.3, 'source': 'soulseek'}, } @@ -255,7 +255,7 @@ wishlist_stats_state = copy.deepcopy(_DEFAULT_WISHLIST_STATS) def _build_status_payload(): return { - 'spotify': dict(_status_cache['spotify']), + 'metadata_source': dict(_status_cache['metadata_source']), 'media_server': dict(_status_cache['media_server']), 'soulseek': dict(_status_cache['soulseek']), 'active_media_server': _status_cache['media_server'].get('type', 'plex'), diff --git a/tests/test_websocket_infrastructure.py b/tests/test_websocket_infrastructure.py index 392f80aa..dc3d81bf 100644 --- a/tests/test_websocket_infrastructure.py +++ b/tests/test_websocket_infrastructure.py @@ -52,11 +52,11 @@ class TestServiceStatus: status_events = [e for e in received if e['name'] == 'status:update'] assert len(status_events) >= 1 data = status_events[0]['args'][0] - assert 'spotify' in data + assert 'metadata_source' in data assert 'media_server' in data assert 'soulseek' in data assert 'active_media_server' in data - assert 'authenticated' in data['spotify'] + assert 'authenticated' in data['metadata_source'] def test_status_matches_http(self, test_app, shared_state): """Socket event data matches HTTP endpoint response exactly.""" @@ -73,7 +73,7 @@ class TestServiceStatus: assert len(status_events) >= 1 ws_data = status_events[0]['args'][0] - assert ws_data['spotify'] == http_data['spotify'] + assert ws_data['metadata_source'] == http_data['metadata_source'] assert ws_data['media_server'] == http_data['media_server'] assert ws_data['soulseek'] == http_data['soulseek'] assert ws_data['active_media_server'] == http_data['active_media_server'] @@ -86,13 +86,13 @@ class TestServiceStatus: build = shared_state['build_status_payload'] # Mutate cache - status_cache['spotify']['source'] = 'itunes' + status_cache['metadata_source']['source'] = 'itunes' socketio.emit('status:update', build()) received = client.get_received() status_events = [e for e in received if e['name'] == 'status:update'] data = status_events[-1]['args'][0] - assert data['spotify']['source'] == 'itunes' + assert data['metadata_source']['source'] == 'itunes' # ========================================================================= diff --git a/web_server.py b/web_server.py index 2b4be31d..72cfd393 100644 --- a/web_server.py +++ b/web_server.py @@ -811,12 +811,12 @@ _idle_since = {} _IDLE_GRACE_SECONDS = 5 _status_cache = { - 'spotify': {'connected': False, 'authenticated': False, 'response_time': 0, 'source': 'itunes'}, + 'metadata_source': {'connected': False, 'authenticated': False, 'response_time': 0, 'source': 'itunes'}, 'media_server': {'connected': False, 'response_time': 0, 'type': None}, 'soulseek': {'connected': False, 'response_time': 0}, } _status_cache_timestamps: dict[str, float] = { - 'spotify': 0, + 'metadata_source': 0, 'media_server': 0, 'soulseek': 0, } @@ -3447,12 +3447,12 @@ def get_status(): active_server = config_manager.get_active_media_server() # Test Spotify - with caching to avoid excessive API calls - if current_time - _status_cache_timestamps['spotify'] > STATUS_CACHE_TTL: + if current_time - _status_cache_timestamps['metadata_source'] > STATUS_CACHE_TTL: # Guard against spotify_client being None (partial init) is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 - spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False + metadata_source_authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False # Read configured source once — no auth validation here, we do that explicitly below configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' @@ -3461,27 +3461,27 @@ def get_status(): # During rate limit or post-ban cooldown, skip the auth probe entirely. # Probing Spotify here would reset the rate limit timer. music_source = 'deezer' if configured_source == 'spotify' else configured_source - spotify_response_time = 0 + metadata_source_response_time = 0 else: - spotify_start = time.time() - spotify_connected = spotify_client.is_spotify_authenticated() if spotify_client else False - spotify_response_time = (time.time() - spotify_start) * 1000 + metadata_source_start = time.time() + metadata_source_connected = spotify_client.is_spotify_authenticated() if spotify_client else False + metadata_source_response_time = (time.time() - metadata_source_start) * 1000 # Use configured source; fall back to deezer only if Spotify isn't authenticated if configured_source == 'spotify': - music_source = 'spotify' if spotify_connected else 'deezer' + music_source = 'spotify' if metadata_source_connected else 'deezer' else: music_source = configured_source - _status_cache['spotify'] = { - 'connected': spotify_session_active, - 'authenticated': spotify_session_active, - 'response_time': round(spotify_response_time, 1), + _status_cache['metadata_source'] = { + 'connected': metadata_source_authenticated, + 'authenticated': metadata_source_authenticated, + 'response_time': round(metadata_source_response_time, 1), 'source': music_source, 'rate_limited': is_rate_limited, 'rate_limit': rate_limit_info, 'post_ban_cooldown': cooldown_remaining if cooldown_remaining > 0 else None } - _status_cache_timestamps['spotify'] = current_time + _status_cache_timestamps['metadata_source'] = current_time # else: use cached value # Test media server - use EXISTING instances (they have internal caching) @@ -3556,7 +3556,7 @@ def get_status(): active_dl_count += 1 status_data = { - 'spotify': _status_cache['spotify'], + 'metadata_source': _status_cache['metadata_source'], 'media_server': _status_cache['media_server'], 'soulseek': _status_cache['soulseek'], 'active_media_server': active_server, @@ -4186,7 +4186,7 @@ def handle_settings(): if tidal_enrichment_worker: tidal_enrichment_worker.client = tidal_client # Invalidate status cache so next poll reflects new settings (e.g. fallback source change) - _status_cache_timestamps['spotify'] = 0 + _status_cache_timestamps['metadata_source'] = 0 logger.info("Service clients re-initialized with new settings.") return jsonify({"success": True, "message": "Settings saved successfully."}) except Exception as e: @@ -4804,10 +4804,10 @@ def test_connection_endpoint(): current_time = time.time() if service == 'spotify': spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False - _status_cache['spotify']['connected'] = True - _status_cache['spotify']['authenticated'] = spotify_session_active - _status_cache['spotify']['source'] = _get_metadata_fallback_source() - _status_cache_timestamps['spotify'] = current_time + _status_cache['metadata_source']['connected'] = True + _status_cache['metadata_source']['authenticated'] = spotify_session_active + _status_cache['metadata_source']['source'] = _get_metadata_fallback_source() + _status_cache_timestamps['metadata_source'] = current_time logger.info("Updated Spotify status cache after successful test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True @@ -4972,10 +4972,10 @@ def test_dashboard_connection_endpoint(): current_time = time.time() if service == 'spotify': spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False - _status_cache['spotify']['connected'] = True - _status_cache['spotify']['authenticated'] = spotify_session_active - _status_cache['spotify']['source'] = _get_metadata_fallback_source() - _status_cache_timestamps['spotify'] = current_time + _status_cache['metadata_source']['connected'] = True + _status_cache['metadata_source']['authenticated'] = spotify_session_active + _status_cache['metadata_source']['source'] = _get_metadata_fallback_source() + _status_cache_timestamps['metadata_source'] = current_time logger.info("Updated Spotify status cache after successful dashboard test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True @@ -5854,7 +5854,7 @@ def spotify_callback(): return _spotify_auth_result_page("Your personal Spotify account is now connected. You can close this window.", authenticated=True) if profile_client: profile_client._invalidate_auth_cache() - _status_cache_timestamps['spotify'] = 0 + _status_cache_timestamps['metadata_source'] = 0 add_activity_item("", "Spotify Auth Warning", f"Profile {profile_id_from_state} completed OAuth but Spotify did not confirm an authenticated session", "Now") return _spotify_auth_result_page( "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session for this profile. You can close this window and try Authenticate again.", @@ -5890,7 +5890,7 @@ def spotify_callback(): _clear_rate_limit() spotify_client._invalidate_auth_cache() # Invalidate status cache so next poll picks up the new connection - _status_cache_timestamps['spotify'] = 0 + _status_cache_timestamps['metadata_source'] = 0 # Refresh enrichment worker's client so it picks up new auth if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): spotify_enrichment_worker.client.reload_config() @@ -5900,7 +5900,7 @@ def spotify_callback(): else: logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed") spotify_client._invalidate_auth_cache() - _status_cache_timestamps['spotify'] = 0 + _status_cache_timestamps['metadata_source'] = 0 add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") return _spotify_auth_result_page( "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session. You can close this window and try Authenticate again.", @@ -5929,7 +5929,7 @@ def spotify_disconnect(): source_label = get_metadata_source_label(active_source) if configured_source == 'spotify': config_manager.set('metadata.fallback_source', active_source) - _status_cache['spotify'] = { + _status_cache['metadata_source'] = { 'connected': False, 'authenticated': False, 'response_time': 0, @@ -5938,7 +5938,7 @@ def spotify_disconnect(): 'rate_limit': None, 'post_ban_cooldown': None } - _status_cache_timestamps['spotify'] = time.time() + _status_cache_timestamps['metadata_source'] = time.time() add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now") return jsonify({ 'success': True, @@ -32073,7 +32073,7 @@ def start_oauth_callback_servers(): _clear_rate_limit() spotify_client._invalidate_auth_cache() # Invalidate status cache so next poll picks up the new connection - _status_cache_timestamps['spotify'] = 0 + _status_cache_timestamps['metadata_source'] = 0 # Refresh enrichment worker's client so it picks up new auth if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): spotify_enrichment_worker.client.reload_config() @@ -32086,7 +32086,7 @@ def start_oauth_callback_servers(): else: _oauth_logger.warning("Spotify token exchange succeeded but authentication validation failed") spotify_client._invalidate_auth_cache() - _status_cache_timestamps['spotify'] = 0 + _status_cache_timestamps['metadata_source'] = 0 add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") self.send_response(200) self.send_header('Content-type', 'text/html') @@ -34053,14 +34053,14 @@ def _build_status_payload(): # Always include fresh rate limit info (it changes over time as ban expires) # Call is_rate_limited() first to ensure ban-end timestamp is recorded for cooldown - spotify_data = dict(_status_cache.get('spotify', {})) + metadata_source_data = dict(_status_cache.get('metadata_source', {})) is_rl = spotify_client.is_rate_limited() if spotify_client else False rate_limit_info = spotify_client.get_rate_limit_info() if is_rl else None cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 - spotify_data['rate_limited'] = is_rl - spotify_data['rate_limit'] = rate_limit_info + metadata_source_data['rate_limited'] = is_rl + metadata_source_data['rate_limit'] = rate_limit_info if cooldown_remaining > 0: - spotify_data['post_ban_cooldown'] = cooldown_remaining + metadata_source_data['post_ban_cooldown'] = cooldown_remaining # Count active downloads for nav badge active_dl_count = 0 @@ -34073,7 +34073,7 @@ def _build_status_payload(): pass return { - 'spotify': spotify_data, + 'metadata_source': metadata_source_data, 'media_server': _status_cache.get('media_server', {}), 'soulseek': soulseek_data, 'active_media_server': config_manager.get_active_media_server(), diff --git a/webui/static/core.js b/webui/static/core.js index 4e3b6ad8..cf0f4809 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -471,24 +471,24 @@ function initializeWebSocket() { function handleServiceStatusUpdate(data) { // Cache for library status card - _lastServiceStatus = data; + _lastStatusPayload = data; if (typeof syncSpotifySettingsAuthState === 'function') { - syncSpotifySettingsAuthState(data?.spotify || null); + syncSpotifySettingsAuthState(data?.metadata_source || null); } if (typeof syncPrimaryMetadataSourceAvailability === 'function') { - syncPrimaryMetadataSourceAvailability(data?.spotify || null); + syncPrimaryMetadataSourceAvailability(data?.metadata_source || null); } if (typeof sanitizeMetadataSourceSelection === 'function') { sanitizeMetadataSourceSelection({ quiet: true }); } // Same logic as fetchAndUpdateServiceStatus response handler - updateServiceStatus('spotify', data.spotify); + updateServiceStatus('spotify', data.metadata_source); updateServiceStatus('media-server', data.media_server); updateServiceStatus('soulseek', data.soulseek); - updateSidebarServiceStatus('spotify', data.spotify); + updateSidebarServiceStatus('spotify', data.metadata_source); updateSidebarServiceStatus('media-server', data.media_server); updateSidebarServiceStatus('soulseek', data.soulseek); @@ -514,10 +514,10 @@ function handleServiceStatusUpdate(data) { if (data.enrichment) renderEnrichmentCards(data.enrichment); // Spotify rate limit / cooldown / recovery - if (data.spotify?.rate_limited && data.spotify.rate_limit) { - handleSpotifyRateLimit(data.spotify.rate_limit); + if (data.metadata_source?.rate_limited && data.metadata_source.rate_limit) { + handleSpotifyRateLimit(data.metadata_source.rate_limit); _spotifyInCooldown = false; - } else if (data.spotify?.post_ban_cooldown > 0) { + } else if (data.metadata_source?.post_ban_cooldown > 0) { if (_spotifyRateLimitShown && !_spotifyInCooldown) { _spotifyRateLimitShown = false; _spotifyInCooldown = true; @@ -881,12 +881,12 @@ const API = { } }; -// Track last service status for library card (used by websocket handler in core + artists) -let _lastServiceStatus = null; +// Track the last `/status` payload (shared service snapshot used across the UI) +let _lastStatusPayload = null; let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden) function getActiveMetadataSource() { - return _lastServiceStatus?.spotify?.source || 'spotify'; + return _lastStatusPayload?.metadata_source?.source || 'spotify'; } // =============================== diff --git a/webui/static/helper.js b/webui/static/helper.js index 3afabac5..a8f97688 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -2974,13 +2974,13 @@ async function _checkSetupStatus() { const completion = _getSetupCompletion(); const results = { ...completion }; - // ── /status — checks services (spotify, media_server, soulseek) ───── + // ── /status — checks metadata_source, media_server, soulseek ──────── try { const resp = await fetch('/status'); if (resp.ok) { const data = await resp.json(); // Metadata source is available when status reports a source. - if (data.spotify?.source) { + if (data.metadata_source?.source) { results['metadata-source'] = results['metadata-source'] || Date.now(); _markSetupComplete('metadata-source'); } diff --git a/webui/static/settings.js b/webui/static/settings.js index 8b9aa005..6307e575 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -60,7 +60,7 @@ function syncMetadataSourceSelection(source) { function _isMetadataSourceSelectable(source) { if (source === 'spotify') { - return _lastServiceStatus?.spotify?.authenticated === true; + return _lastStatusPayload?.metadata_source?.authenticated === true; } if (source === 'discogs') { const token = document.getElementById('discogs-token'); @@ -173,7 +173,7 @@ function initializeSettings() { if (discogsTokenInput) { discogsTokenInput.addEventListener('input', () => { if (typeof syncPrimaryMetadataSourceAvailability === 'function') { - syncPrimaryMetadataSourceAvailability(_lastServiceStatus?.spotify || null); + syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.metadata_source || null); } sanitizeMetadataSourceSelection({ quiet: true }); }); @@ -207,10 +207,10 @@ function initializeSettings() { // Test button event listeners removed - they use onclick attributes in HTML to avoid double firing if (typeof syncPrimaryMetadataSourceAvailability === 'function') { - syncPrimaryMetadataSourceAvailability(_lastServiceStatus?.spotify || null); + syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.metadata_source || null); } - syncSpotifySettingsAuthState(_lastServiceStatus?.spotify || null); - syncMetadataSourceSelection(_lastServiceStatus?.spotify?.source); + syncSpotifySettingsAuthState(_lastStatusPayload?.metadata_source || null); + syncMetadataSourceSelection(_lastStatusPayload?.metadata_source?.source); sanitizeMetadataSourceSelection({ quiet: true }); if (metadataSourceSelect) { metadataSourceSelect.dataset.lastValidSource = metadataSourceSelect.value; @@ -408,7 +408,7 @@ async function applyServiceStatusGradients() { else header.appendChild(spinner); } }); - syncSpotifySettingsAuthState(_lastServiceStatus?.spotify || null); + syncSpotifySettingsAuthState(_lastStatusPayload?.metadata_source || null); } catch (e) { console.warn('[Settings Status] Failed to apply gradients:', e); } @@ -2544,7 +2544,7 @@ async function saveSettings(quiet = false) { const discogsTokenInput = document.getElementById('discogs-token'); const discogsTokenPresent = !!discogsTokenInput?.value?.trim(); let metadataSource = metadataSourceSelect?.value || 'itunes'; - const spotifySessionActive = _lastServiceStatus?.spotify?.authenticated === true; + const spotifySessionActive = _lastStatusPayload?.metadata_source?.authenticated === true; if (metadataSource === 'spotify' && !spotifySessionActive) { metadataSource = 'deezer'; if (metadataSourceSelect) metadataSourceSelect.value = metadataSource; diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 29b2a1fa..7b675fd3 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -3143,25 +3143,25 @@ async function fetchAndUpdateServiceStatus() { const data = await response.json(); // Cache for library status card - _lastServiceStatus = data; + _lastStatusPayload = data; if (typeof syncSpotifySettingsAuthState === 'function') { - syncSpotifySettingsAuthState(data?.spotify || null); + syncSpotifySettingsAuthState(data?.metadata_source || null); } if (typeof syncPrimaryMetadataSourceAvailability === 'function') { - syncPrimaryMetadataSourceAvailability(data?.spotify || null); + syncPrimaryMetadataSourceAvailability(data?.metadata_source || null); } if (typeof sanitizeMetadataSourceSelection === 'function') { sanitizeMetadataSourceSelection({ quiet: true }); } // Update service status indicators and text (dashboard) - updateServiceStatus('spotify', data.spotify); + updateServiceStatus('spotify', data.metadata_source); updateServiceStatus('media-server', data.media_server); updateServiceStatus('soulseek', data.soulseek); // Update sidebar service status indicators - updateSidebarServiceStatus('spotify', data.spotify); + updateSidebarServiceStatus('spotify', data.metadata_source); updateSidebarServiceStatus('media-server', data.media_server); updateSidebarServiceStatus('soulseek', data.soulseek); @@ -3185,8 +3185,8 @@ async function fetchAndUpdateServiceStatus() { if (data.enrichment) renderEnrichmentCards(data.enrichment); // Check for Spotify rate limit - if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) { - handleSpotifyRateLimit(data.spotify.rate_limit); + if (data.metadata_source && data.metadata_source.rate_limited && data.metadata_source.rate_limit) { + handleSpotifyRateLimit(data.metadata_source.rate_limit); } else if (_spotifyRateLimitShown) { handleSpotifyRateLimit(null); } diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index 3d40bb63..15766b5d 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -6558,8 +6558,8 @@ function updateLibraryStatusCard(dbStats) { const isScanning = window._libraryStatusScanning || false; // Determine state - const serverConnected = _lastServiceStatus && _lastServiceStatus.media_server && _lastServiceStatus.media_server.connected; - const serverType = _lastServiceStatus && _lastServiceStatus.active_media_server; + const serverConnected = _lastStatusPayload && _lastStatusPayload.media_server && _lastStatusPayload.media_server.connected; + const serverType = _lastStatusPayload && _lastStatusPayload.active_media_server; const hasData = tracks > 0; const hasServer = !!serverType && serverType !== 'none'; @@ -6668,7 +6668,7 @@ function updateLibraryStatusCard(dbStats) { } } -// _lastServiceStatus and _isSoulsyncStandalone are declared in core.js +// _lastStatusPayload and _isSoulsyncStandalone are declared in core.js const _origFetchServiceStatus = typeof fetchAndUpdateServiceStatus === 'function' ? fetchAndUpdateServiceStatus : null; function _capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; } From e2bd0e1871115f70d7c035ec691e453b38574ba7 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 11:05:01 +0300 Subject: [PATCH 04/11] Split metadata source and Spotify status - Keep the primary metadata provider snapshot generic and move Spotify auth/rate-limit details into a separate status object. - Update the websocket fixture and dashboard/settings consumers to read the two buckets independently. --- core/metadata/registry.py | 39 +++++++++++ tests/conftest.py | 4 +- tests/test_websocket_infrastructure.py | 5 +- web_server.py | 95 +++++++++----------------- webui/static/core.js | 14 ++-- webui/static/settings.js | 12 ++-- webui/static/shared-helpers.js | 58 ++++++++-------- 7 files changed, 121 insertions(+), 106 deletions(-) diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 71206cfe..4a6de606 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -9,6 +9,7 @@ from __future__ import annotations import threading import hashlib +import time from typing import Any, Callable, Dict, Optional from utils.logging_config import get_logger @@ -341,6 +342,44 @@ def get_primary_client( ) +def get_primary_source_status( + *, + spotify_client_factory: Optional[MetadataClientFactory] = None, + itunes_client_factory: Optional[MetadataClientFactory] = None, + deezer_client_factory: Optional[MetadataClientFactory] = None, + discogs_client_factory: Optional[MetadataClientFactory] = None, +) -> Dict[str, Any]: + """Return a generic status snapshot for the active primary metadata source.""" + source = _get_config_value("metadata.fallback_source", "deezer") or "deezer" + started = time.time() + connected = False + + try: + client = get_client_for_source( + source, + spotify_client_factory=spotify_client_factory, + itunes_client_factory=itunes_client_factory, + deezer_client_factory=deezer_client_factory, + discogs_client_factory=discogs_client_factory, + ) + if source == "spotify": + connected = bool(client and client.is_spotify_authenticated()) + elif source == "hydrabase": + connected = bool(client and (client.is_connected() if hasattr(client, "is_connected") else client.is_authenticated())) + elif client is not None and hasattr(client, "is_authenticated"): + connected = bool(client.is_authenticated()) + else: + connected = client is not None + except Exception: + connected = False + + return { + "source": source, + "connected": connected, + "response_time": round((time.time() - started) * 1000, 1), + } + + def get_client_for_source( source: str, *, diff --git a/tests/conftest.py b/tests/conftest.py index 4adc2f96..2ee99421 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,8 @@ from flask_socketio import SocketIO, join_room, leave_room # --------------------------------------------------------------------------- _DEFAULT_STATUS_CACHE = { - 'metadata_source': {'connected': True, 'authenticated': True, 'response_time': 12.5, 'source': 'spotify'}, + 'metadata_source': {'connected': True, 'response_time': 12.5, 'source': 'spotify'}, + 'spotify': {'connected': True, 'authenticated': True, 'rate_limited': False, 'rate_limit': None, 'post_ban_cooldown': None}, 'media_server': {'connected': True, 'response_time': 8.1, 'type': 'plex'}, 'soulseek': {'connected': True, 'response_time': 5.3, 'source': 'soulseek'}, } @@ -256,6 +257,7 @@ wishlist_stats_state = copy.deepcopy(_DEFAULT_WISHLIST_STATS) def _build_status_payload(): return { 'metadata_source': dict(_status_cache['metadata_source']), + 'spotify': dict(_status_cache['spotify']), 'media_server': dict(_status_cache['media_server']), 'soulseek': dict(_status_cache['soulseek']), 'active_media_server': _status_cache['media_server'].get('type', 'plex'), diff --git a/tests/test_websocket_infrastructure.py b/tests/test_websocket_infrastructure.py index dc3d81bf..4e17eead 100644 --- a/tests/test_websocket_infrastructure.py +++ b/tests/test_websocket_infrastructure.py @@ -53,10 +53,12 @@ class TestServiceStatus: assert len(status_events) >= 1 data = status_events[0]['args'][0] assert 'metadata_source' in data + assert 'spotify' in data assert 'media_server' in data assert 'soulseek' in data assert 'active_media_server' in data - assert 'authenticated' in data['metadata_source'] + assert 'source' in data['metadata_source'] + assert 'authenticated' in data['spotify'] def test_status_matches_http(self, test_app, shared_state): """Socket event data matches HTTP endpoint response exactly.""" @@ -74,6 +76,7 @@ class TestServiceStatus: ws_data = status_events[0]['args'][0] assert ws_data['metadata_source'] == http_data['metadata_source'] + assert ws_data['spotify'] == http_data['spotify'] assert ws_data['media_server'] == http_data['media_server'] assert ws_data['soulseek'] == http_data['soulseek'] assert ws_data['active_media_server'] == http_data['active_media_server'] diff --git a/web_server.py b/web_server.py index 72cfd393..c78d9be6 100644 --- a/web_server.py +++ b/web_server.py @@ -811,7 +811,7 @@ _idle_since = {} _IDLE_GRACE_SECONDS = 5 _status_cache = { - 'metadata_source': {'connected': False, 'authenticated': False, 'response_time': 0, 'source': 'itunes'}, + 'metadata_source': {'connected': False, 'response_time': 0, 'source': 'itunes'}, 'media_server': {'connected': False, 'response_time': 0, 'type': None}, 'soulseek': {'connected': False, 'response_time': 0}, } @@ -3446,41 +3446,9 @@ def get_status(): current_time = time.time() active_server = config_manager.get_active_media_server() - # Test Spotify - with caching to avoid excessive API calls + # Test primary metadata provider and Spotify separately if current_time - _status_cache_timestamps['metadata_source'] > STATUS_CACHE_TTL: - # Guard against spotify_client being None (partial init) - is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False - rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None - cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 - metadata_source_authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False - - # Read configured source once — no auth validation here, we do that explicitly below - configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' - - if is_rate_limited or cooldown_remaining > 0: - # During rate limit or post-ban cooldown, skip the auth probe entirely. - # Probing Spotify here would reset the rate limit timer. - music_source = 'deezer' if configured_source == 'spotify' else configured_source - metadata_source_response_time = 0 - else: - metadata_source_start = time.time() - metadata_source_connected = spotify_client.is_spotify_authenticated() if spotify_client else False - metadata_source_response_time = (time.time() - metadata_source_start) * 1000 - # Use configured source; fall back to deezer only if Spotify isn't authenticated - if configured_source == 'spotify': - music_source = 'spotify' if metadata_source_connected else 'deezer' - else: - music_source = configured_source - - _status_cache['metadata_source'] = { - 'connected': metadata_source_authenticated, - 'authenticated': metadata_source_authenticated, - 'response_time': round(metadata_source_response_time, 1), - 'source': music_source, - 'rate_limited': is_rate_limited, - 'rate_limit': rate_limit_info, - 'post_ban_cooldown': cooldown_remaining if cooldown_remaining > 0 else None - } + _status_cache['metadata_source'] = metadata_registry.get_primary_source_status() _status_cache_timestamps['metadata_source'] = current_time # else: use cached value @@ -3557,6 +3525,7 @@ def get_status(): status_data = { 'metadata_source': _status_cache['metadata_source'], + 'spotify': _build_spotify_status_payload(), 'media_server': _status_cache['media_server'], 'soulseek': _status_cache['soulseek'], 'active_media_server': active_server, @@ -4804,10 +4773,7 @@ def test_connection_endpoint(): current_time = time.time() if service == 'spotify': spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False - _status_cache['metadata_source']['connected'] = True - _status_cache['metadata_source']['authenticated'] = spotify_session_active - _status_cache['metadata_source']['source'] = _get_metadata_fallback_source() - _status_cache_timestamps['metadata_source'] = current_time + _status_cache_timestamps['metadata_source'] = 0 logger.info("Updated Spotify status cache after successful test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True @@ -4972,10 +4938,7 @@ def test_dashboard_connection_endpoint(): current_time = time.time() if service == 'spotify': spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False - _status_cache['metadata_source']['connected'] = True - _status_cache['metadata_source']['authenticated'] = spotify_session_active - _status_cache['metadata_source']['source'] = _get_metadata_fallback_source() - _status_cache_timestamps['metadata_source'] = current_time + _status_cache_timestamps['metadata_source'] = 0 logger.info("Updated Spotify status cache after successful dashboard test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True @@ -5929,16 +5892,7 @@ def spotify_disconnect(): source_label = get_metadata_source_label(active_source) if configured_source == 'spotify': config_manager.set('metadata.fallback_source', active_source) - _status_cache['metadata_source'] = { - 'connected': False, - 'authenticated': False, - 'response_time': 0, - 'source': active_source, - 'rate_limited': False, - 'rate_limit': None, - 'post_ban_cooldown': None - } - _status_cache_timestamps['metadata_source'] = time.time() + _status_cache_timestamps['metadata_source'] = 0 add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now") return jsonify({ 'success': True, @@ -34050,17 +34004,8 @@ def _build_status_payload(): download_mode = config_manager.get('download_source.mode', 'hybrid') soulseek_data = dict(_status_cache.get('soulseek', {})) soulseek_data['source'] = download_mode - - # Always include fresh rate limit info (it changes over time as ban expires) - # Call is_rate_limited() first to ensure ban-end timestamp is recorded for cooldown metadata_source_data = dict(_status_cache.get('metadata_source', {})) - is_rl = spotify_client.is_rate_limited() if spotify_client else False - rate_limit_info = spotify_client.get_rate_limit_info() if is_rl else None - cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 - metadata_source_data['rate_limited'] = is_rl - metadata_source_data['rate_limit'] = rate_limit_info - if cooldown_remaining > 0: - metadata_source_data['post_ban_cooldown'] = cooldown_remaining + spotify_data = _build_spotify_status_payload() # Count active downloads for nav badge active_dl_count = 0 @@ -34074,6 +34019,7 @@ def _build_status_payload(): return { 'metadata_source': metadata_source_data, + 'spotify': spotify_data, 'media_server': _status_cache.get('media_server', {}), 'soulseek': soulseek_data, 'active_media_server': config_manager.get_active_media_server(), @@ -34081,6 +34027,29 @@ def _build_status_payload(): 'active_downloads': active_dl_count, } +def _build_spotify_status_payload(): + """Build a Spotify-specific status snapshot for auth and rate-limit state.""" + status_data = {} + + try: + # Always include fresh rate limit info because it can change independently of cache TTL. + is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False + rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None + cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 + authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False + + status_data.update({ + 'connected': authenticated, + 'authenticated': authenticated, + 'rate_limited': is_rate_limited, + 'rate_limit': rate_limit_info, + 'post_ban_cooldown': cooldown_remaining if cooldown_remaining > 0 else None, + }) + except Exception: + pass + + return status_data + def _build_watchlist_count_payload(profile_id=1): """Build the same payload used by GET /api/watchlist/count.""" try: diff --git a/webui/static/core.js b/webui/static/core.js index cf0f4809..27b90003 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -474,21 +474,21 @@ function handleServiceStatusUpdate(data) { _lastStatusPayload = data; if (typeof syncSpotifySettingsAuthState === 'function') { - syncSpotifySettingsAuthState(data?.metadata_source || null); + syncSpotifySettingsAuthState(data?.spotify || null); } if (typeof syncPrimaryMetadataSourceAvailability === 'function') { - syncPrimaryMetadataSourceAvailability(data?.metadata_source || null); + syncPrimaryMetadataSourceAvailability(data?.spotify || null); } if (typeof sanitizeMetadataSourceSelection === 'function') { sanitizeMetadataSourceSelection({ quiet: true }); } // Same logic as fetchAndUpdateServiceStatus response handler - updateServiceStatus('spotify', data.metadata_source); + updateServiceStatus('spotify', data.metadata_source, data.spotify); updateServiceStatus('media-server', data.media_server); updateServiceStatus('soulseek', data.soulseek); - updateSidebarServiceStatus('spotify', data.metadata_source); + updateSidebarServiceStatus('spotify', data.metadata_source, data.spotify); updateSidebarServiceStatus('media-server', data.media_server); updateSidebarServiceStatus('soulseek', data.soulseek); @@ -514,10 +514,10 @@ function handleServiceStatusUpdate(data) { if (data.enrichment) renderEnrichmentCards(data.enrichment); // Spotify rate limit / cooldown / recovery - if (data.metadata_source?.rate_limited && data.metadata_source.rate_limit) { - handleSpotifyRateLimit(data.metadata_source.rate_limit); + if (data.spotify?.rate_limited && data.spotify.rate_limit) { + handleSpotifyRateLimit(data.spotify.rate_limit); _spotifyInCooldown = false; - } else if (data.metadata_source?.post_ban_cooldown > 0) { + } else if (data.spotify?.post_ban_cooldown > 0) { if (_spotifyRateLimitShown && !_spotifyInCooldown) { _spotifyRateLimitShown = false; _spotifyInCooldown = true; diff --git a/webui/static/settings.js b/webui/static/settings.js index 6307e575..79013d1a 100644 --- a/webui/static/settings.js +++ b/webui/static/settings.js @@ -60,7 +60,7 @@ function syncMetadataSourceSelection(source) { function _isMetadataSourceSelectable(source) { if (source === 'spotify') { - return _lastStatusPayload?.metadata_source?.authenticated === true; + return _lastStatusPayload?.spotify?.authenticated === true; } if (source === 'discogs') { const token = document.getElementById('discogs-token'); @@ -173,7 +173,7 @@ function initializeSettings() { if (discogsTokenInput) { discogsTokenInput.addEventListener('input', () => { if (typeof syncPrimaryMetadataSourceAvailability === 'function') { - syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.metadata_source || null); + syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.spotify || null); } sanitizeMetadataSourceSelection({ quiet: true }); }); @@ -207,9 +207,9 @@ function initializeSettings() { // Test button event listeners removed - they use onclick attributes in HTML to avoid double firing if (typeof syncPrimaryMetadataSourceAvailability === 'function') { - syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.metadata_source || null); + syncPrimaryMetadataSourceAvailability(_lastStatusPayload?.spotify || null); } - syncSpotifySettingsAuthState(_lastStatusPayload?.metadata_source || null); + syncSpotifySettingsAuthState(_lastStatusPayload?.spotify || null); syncMetadataSourceSelection(_lastStatusPayload?.metadata_source?.source); sanitizeMetadataSourceSelection({ quiet: true }); if (metadataSourceSelect) { @@ -408,7 +408,7 @@ async function applyServiceStatusGradients() { else header.appendChild(spinner); } }); - syncSpotifySettingsAuthState(_lastStatusPayload?.metadata_source || null); + syncSpotifySettingsAuthState(_lastStatusPayload?.spotify || null); } catch (e) { console.warn('[Settings Status] Failed to apply gradients:', e); } @@ -2544,7 +2544,7 @@ async function saveSettings(quiet = false) { const discogsTokenInput = document.getElementById('discogs-token'); const discogsTokenPresent = !!discogsTokenInput?.value?.trim(); let metadataSource = metadataSourceSelect?.value || 'itunes'; - const spotifySessionActive = _lastStatusPayload?.metadata_source?.authenticated === true; + const spotifySessionActive = _lastStatusPayload?.spotify?.authenticated === true; if (metadataSource === 'spotify' && !spotifySessionActive) { metadataSource = 'deezer'; if (metadataSourceSelect) metadataSourceSelect.value = metadataSource; diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 7b675fd3..0dc1b086 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -3146,22 +3146,22 @@ async function fetchAndUpdateServiceStatus() { _lastStatusPayload = data; if (typeof syncSpotifySettingsAuthState === 'function') { - syncSpotifySettingsAuthState(data?.metadata_source || null); + syncSpotifySettingsAuthState(data?.spotify || null); } if (typeof syncPrimaryMetadataSourceAvailability === 'function') { - syncPrimaryMetadataSourceAvailability(data?.metadata_source || null); + syncPrimaryMetadataSourceAvailability(data?.spotify || null); } if (typeof sanitizeMetadataSourceSelection === 'function') { sanitizeMetadataSourceSelection({ quiet: true }); } // Update service status indicators and text (dashboard) - updateServiceStatus('spotify', data.metadata_source); + updateServiceStatus('spotify', data.metadata_source, data.spotify); updateServiceStatus('media-server', data.media_server); updateServiceStatus('soulseek', data.soulseek); // Update sidebar service status indicators - updateSidebarServiceStatus('spotify', data.metadata_source); + updateSidebarServiceStatus('spotify', data.metadata_source, data.spotify); updateSidebarServiceStatus('media-server', data.media_server); updateSidebarServiceStatus('soulseek', data.soulseek); @@ -3185,8 +3185,8 @@ async function fetchAndUpdateServiceStatus() { if (data.enrichment) renderEnrichmentCards(data.enrichment); // Check for Spotify rate limit - if (data.metadata_source && data.metadata_source.rate_limited && data.metadata_source.rate_limit) { - handleSpotifyRateLimit(data.metadata_source.rate_limit); + if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) { + handleSpotifyRateLimit(data.spotify.rate_limit); } else if (_spotifyRateLimitShown) { handleSpotifyRateLimit(null); } @@ -3232,14 +3232,16 @@ function getMetadataSourceLabel(source) { return 'Unmapped'; } -function getSpotifyStatusPresentation(statusData) { - const sourceLabel = getMetadataSourceLabel(statusData?.source); - const rateLimited = !!(statusData?.rate_limited && statusData?.rate_limit); - const cooldown = !!(statusData?.post_ban_cooldown > 0); - const sessionActive = statusData?.authenticated === true || (statusData?.authenticated === undefined && statusData?.source === 'spotify'); +function getMetadataSourcePresentation(metadataStatus, spotifyStatus) { + const source = metadataStatus?.source; + const sourceLabel = getMetadataSourceLabel(source); + const connected = metadataStatus?.connected === true; + const sessionActive = spotifyStatus?.authenticated === true || (source === 'spotify' && connected); + const rateLimited = !!(source === 'spotify' && spotifyStatus?.rate_limited && spotifyStatus?.rate_limit); + const cooldown = !!(source === 'spotify' && spotifyStatus?.post_ban_cooldown > 0); if (rateLimited) { - const remaining = statusData.rate_limit?.remaining_seconds || 0; + const remaining = spotifyStatus.rate_limit?.remaining_seconds || 0; return { statusClass: 'rate-limited', statusText: `Spotify paused \u2014 ${formatRateLimitDuration(remaining)}`, @@ -3250,7 +3252,7 @@ function getSpotifyStatusPresentation(statusData) { } if (cooldown) { - const remaining = statusData.post_ban_cooldown; + const remaining = spotifyStatus.post_ban_cooldown; return { statusClass: 'rate-limited', statusText: `Spotify recovering \u2014 ${formatRateLimitDuration(remaining)}`, @@ -3260,32 +3262,32 @@ function getSpotifyStatusPresentation(statusData) { }; } - if (statusData?.source && statusData.source !== 'spotify') { + if (source) { return { - statusClass: 'connected', - statusText: sourceLabel, - dotClass: 'connected', - dotTitle: sourceLabel, + statusClass: connected ? 'connected' : 'disconnected', + statusText: connected ? (source === 'spotify' ? `Connected (${metadataStatus?.response_time}ms)` : sourceLabel) : 'Disconnected', + dotClass: connected ? 'connected' : 'disconnected', + dotTitle: connected ? sourceLabel : 'Disconnected', sessionActive }; } return { - statusClass: 'connected', - statusText: `Connected (${statusData?.response_time}ms)`, - dotClass: 'connected', - dotTitle: '', + statusClass: 'disconnected', + statusText: 'Disconnected', + dotClass: 'disconnected', + dotTitle: 'Disconnected', sessionActive }; } -function updateServiceStatus(service, statusData) { +function updateServiceStatus(service, statusData, spotifyStatus = null) { const indicator = document.getElementById(`${service}-status-indicator`); const statusText = document.getElementById(`${service}-status-text`); if (indicator && statusText) { if (service === 'spotify') { - const presentation = getSpotifyStatusPresentation(statusData || {}); + const presentation = getMetadataSourcePresentation(statusData || {}, spotifyStatus || {}); indicator.className = `service-card-indicator ${presentation.statusClass}`; statusText.textContent = presentation.statusText; statusText.className = `service-card-status-text ${presentation.statusClass}`; @@ -3312,7 +3314,7 @@ function updateServiceStatus(service, statusData) { } // Keep the Spotify action buttons aligned with the actual auth session. - const spotifySessionActive = getSpotifyStatusPresentation(statusData || {}).sessionActive; + const spotifySessionActive = spotifyStatus?.authenticated === true; const authBtn = document.querySelector('button[onclick="authenticateSpotify()"]'); const disconnectBtn = document.getElementById('spotify-disconnect-btn'); if (authBtn) { @@ -3322,7 +3324,7 @@ function updateServiceStatus(service, statusData) { disconnectBtn.style.display = spotifySessionActive ? '' : 'none'; } - syncPrimaryMetadataSourceAvailability(statusData); + syncPrimaryMetadataSourceAvailability(spotifyStatus); const responseTimeElement = document.getElementById('spotify-response-time'); if (responseTimeElement) { @@ -3348,7 +3350,7 @@ function updateServiceStatus(service, statusData) { } } -function updateSidebarServiceStatus(service, statusData) { +function updateSidebarServiceStatus(service, statusData, spotifyStatus = null) { const indicator = document.getElementById(`${service}-indicator`); if (indicator) { const dot = indicator.querySelector('.status-dot'); @@ -3356,7 +3358,7 @@ function updateSidebarServiceStatus(service, statusData) { if (dot) { if (service === 'spotify') { - const presentation = getSpotifyStatusPresentation(statusData || {}); + const presentation = getMetadataSourcePresentation(statusData || {}, spotifyStatus || {}); dot.className = `status-dot ${presentation.dotClass}`; dot.title = presentation.dotTitle; } else { From 3c7187fb32747d4ee1742d8e9122212b0375740a Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 11:29:43 +0300 Subject: [PATCH 05/11] Reduce Spotify status polling - cache Spotify auth and rate-limit status separately from the generic metadata source snapshot - refresh Spotify status only on explicit auth/disconnect/test paths or after the TTL expires - keep the legacy OAuth callback paths aligned with the same invalidation helper --- web_server.py | 53 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/web_server.py b/web_server.py index c78d9be6..ecb19d5c 100644 --- a/web_server.py +++ b/web_server.py @@ -821,6 +821,24 @@ _status_cache_timestamps: dict[str, float] = { 'soulseek': 0, } STATUS_CACHE_TTL = 120 +SPOTIFY_STATUS_TTL_ACTIVE = 15 +SPOTIFY_STATUS_TTL_IDLE = 300 + +_spotify_status_cache = { + 'connected': False, + 'authenticated': False, + 'rate_limited': False, + 'rate_limit': None, + 'post_ban_cooldown': None, +} +_spotify_status_timestamp = 0.0 + + +def _invalidate_metadata_status_caches(): + """Mark the metadata-source and Spotify status snapshots stale.""" + global _spotify_status_timestamp + _status_cache_timestamps['metadata_source'] = 0 + _spotify_status_timestamp = 0 dev_mode_enabled = False _hydrabase_ws = None @@ -4155,7 +4173,7 @@ def handle_settings(): if tidal_enrichment_worker: tidal_enrichment_worker.client = tidal_client # Invalidate status cache so next poll reflects new settings (e.g. fallback source change) - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() logger.info("Service clients re-initialized with new settings.") return jsonify({"success": True, "message": "Settings saved successfully."}) except Exception as e: @@ -4772,8 +4790,7 @@ def test_connection_endpoint(): if success: current_time = time.time() if service == 'spotify': - spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() logger.info("Updated Spotify status cache after successful test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True @@ -4937,8 +4954,7 @@ def test_dashboard_connection_endpoint(): if success: current_time = time.time() if service == 'spotify': - spotify_session_active = spotify_client.is_spotify_authenticated() if spotify_client else False - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() logger.info("Updated Spotify status cache after successful dashboard test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True @@ -5817,7 +5833,7 @@ def spotify_callback(): return _spotify_auth_result_page("Your personal Spotify account is now connected. You can close this window.", authenticated=True) if profile_client: profile_client._invalidate_auth_cache() - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() add_activity_item("", "Spotify Auth Warning", f"Profile {profile_id_from_state} completed OAuth but Spotify did not confirm an authenticated session", "Now") return _spotify_auth_result_page( "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session for this profile. You can close this window and try Authenticate again.", @@ -5853,7 +5869,7 @@ def spotify_callback(): _clear_rate_limit() spotify_client._invalidate_auth_cache() # Invalidate status cache so next poll picks up the new connection - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() # Refresh enrichment worker's client so it picks up new auth if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): spotify_enrichment_worker.client.reload_config() @@ -5863,7 +5879,7 @@ def spotify_callback(): else: logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed") spotify_client._invalidate_auth_cache() - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") return _spotify_auth_result_page( "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session. You can close this window and try Authenticate again.", @@ -5892,7 +5908,7 @@ def spotify_disconnect(): source_label = get_metadata_source_label(active_source) if configured_source == 'spotify': config_manager.set('metadata.fallback_source', active_source) - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now") return jsonify({ 'success': True, @@ -32027,7 +32043,7 @@ def start_oauth_callback_servers(): _clear_rate_limit() spotify_client._invalidate_auth_cache() # Invalidate status cache so next poll picks up the new connection - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() # Refresh enrichment worker's client so it picks up new auth if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): spotify_enrichment_worker.client.reload_config() @@ -32040,7 +32056,7 @@ def start_oauth_callback_servers(): else: _oauth_logger.warning("Spotify token exchange succeeded but authentication validation failed") spotify_client._invalidate_auth_cache() - _status_cache_timestamps['metadata_source'] = 0 + _invalidate_metadata_status_caches() add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") self.send_response(200) self.send_header('Content-type', 'text/html') @@ -34029,7 +34045,15 @@ def _build_status_payload(): def _build_spotify_status_payload(): """Build a Spotify-specific status snapshot for auth and rate-limit state.""" - status_data = {} + import time + + global _spotify_status_timestamp + current_time = time.time() + configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' + ttl = SPOTIFY_STATUS_TTL_ACTIVE if configured_source == 'spotify' else SPOTIFY_STATUS_TTL_IDLE + + if _spotify_status_timestamp and current_time - _spotify_status_timestamp <= ttl: + return dict(_spotify_status_cache) try: # Always include fresh rate limit info because it can change independently of cache TTL. @@ -34038,17 +34062,18 @@ def _build_spotify_status_payload(): cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False - status_data.update({ + _spotify_status_cache.update({ 'connected': authenticated, 'authenticated': authenticated, 'rate_limited': is_rate_limited, 'rate_limit': rate_limit_info, 'post_ban_cooldown': cooldown_remaining if cooldown_remaining > 0 else None, }) + _spotify_status_timestamp = current_time except Exception: pass - return status_data + return dict(_spotify_status_cache) def _build_watchlist_count_payload(profile_id=1): """Build the same payload used by GET /api/watchlist/count.""" From cc13fb8f0103793979d0c2e3ee624048707a3e93 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 11:34:45 +0300 Subject: [PATCH 06/11] Move metadata status cache into core/metadata - move metadata-source and Spotify status caching out of web_server.py - keep the public /status payload unchanged while shrinking server-side glue - centralize invalidation and TTL handling in core/metadata/status.py --- core/metadata/__init__.py | 16 ++++++ core/metadata/status.py | 115 ++++++++++++++++++++++++++++++++++++++ web_server.py | 91 +++++++----------------------- 3 files changed, 150 insertions(+), 72 deletions(-) create mode 100644 core/metadata/status.py diff --git a/core/metadata/__init__.py b/core/metadata/__init__.py index ea434e48..6cecfefc 100644 --- a/core/metadata/__init__.py +++ b/core/metadata/__init__.py @@ -40,6 +40,15 @@ from core.metadata.registry import ( register_profile_spotify_credentials_provider, register_runtime_clients, ) +from core.metadata.status import ( + METADATA_SOURCE_STATUS_TTL, + SPOTIFY_STATUS_TTL_ACTIVE, + SPOTIFY_STATUS_TTL_IDLE, + get_metadata_source_status, + get_spotify_status, + get_status_snapshot, + invalidate_metadata_status_caches, +) from core.metadata.service import MetadataProvider, MetadataService, get_metadata_service from core.metadata.similar_artists import ( get_musicmap_similar_artists, @@ -48,6 +57,7 @@ from core.metadata.similar_artists import ( __all__ = [ "METADATA_SOURCE_PRIORITY", + "METADATA_SOURCE_STATUS_TTL", "MetadataCache", "MetadataLookupOptions", "MetadataProvider", @@ -71,6 +81,7 @@ __all__ = [ "get_hydrabase_client", "get_itunes_client", "get_metadata_cache", + "get_metadata_source_status", "get_metadata_service", "get_musicmap_similar_artists", "get_primary_client", @@ -78,11 +89,16 @@ __all__ = [ "get_spotify_client_for_profile", "get_registered_runtime_client", "get_spotify_client", + "get_spotify_status", "get_source_priority", + "get_status_snapshot", "iter_artist_discography_completion_events", "iter_musicmap_similar_artist_events", "is_hydrabase_enabled", "register_profile_spotify_credentials_provider", "register_runtime_clients", "resolve_album_reference", + "SPOTIFY_STATUS_TTL_ACTIVE", + "SPOTIFY_STATUS_TTL_IDLE", + "invalidate_metadata_status_caches", ] diff --git a/core/metadata/status.py b/core/metadata/status.py new file mode 100644 index 00000000..0e7c4bbf --- /dev/null +++ b/core/metadata/status.py @@ -0,0 +1,115 @@ +"""Cached metadata-provider and Spotify status snapshots.""" + +from __future__ import annotations + +import threading +import time +from typing import Any, Dict, Optional + +from config.settings import config_manager +from utils.logging_config import get_logger + +from core.metadata.registry import get_primary_source_status + +logger = get_logger("metadata.status") + +METADATA_SOURCE_STATUS_TTL = 120 +SPOTIFY_STATUS_TTL_ACTIVE = 15 +SPOTIFY_STATUS_TTL_IDLE = 300 + +_status_lock = threading.RLock() +_metadata_source_status_cache: Dict[str, Any] = { + "source": "deezer", + "connected": False, + "response_time": 0, +} +_metadata_source_status_timestamp = 0.0 +_spotify_status_cache: Dict[str, Any] = { + "connected": False, + "authenticated": False, + "rate_limited": False, + "rate_limit": None, + "post_ban_cooldown": None, +} +_spotify_status_timestamp = 0.0 + + +def _get_config_value(key: str, default: Any = None) -> Any: + try: + return config_manager.get(key, default) + except Exception: + return default + + +def invalidate_metadata_status_caches() -> None: + """Mark the cached metadata-source and Spotify status snapshots stale.""" + global _metadata_source_status_timestamp, _spotify_status_timestamp + with _status_lock: + _metadata_source_status_timestamp = 0.0 + _spotify_status_timestamp = 0.0 + + +def get_metadata_source_status() -> Dict[str, Any]: + """Return a cached snapshot for the active primary metadata source.""" + global _metadata_source_status_timestamp + + current_time = time.time() + with _status_lock: + if _metadata_source_status_timestamp and current_time - _metadata_source_status_timestamp <= METADATA_SOURCE_STATUS_TTL: + return dict(_metadata_source_status_cache) + + try: + status_data = get_primary_source_status() + except Exception as exc: + logger.debug("Metadata source status refresh failed: %s", exc) + status_data = None + + if status_data: + with _status_lock: + _metadata_source_status_cache.update(status_data) + _metadata_source_status_timestamp = current_time + + with _status_lock: + return dict(_metadata_source_status_cache) + + +def get_spotify_status(spotify_client: Optional[Any] = None) -> Dict[str, Any]: + """Return a cached Spotify-specific status snapshot.""" + global _spotify_status_timestamp + + current_time = time.time() + configured_source = _get_config_value("metadata.fallback_source", "deezer") or "deezer" + ttl = SPOTIFY_STATUS_TTL_ACTIVE if configured_source == "spotify" else SPOTIFY_STATUS_TTL_IDLE + + with _status_lock: + if _spotify_status_timestamp and current_time - _spotify_status_timestamp <= ttl: + return dict(_spotify_status_cache) + + try: + is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False + rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None + cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 + authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False + + with _status_lock: + _spotify_status_cache.update({ + "connected": authenticated, + "authenticated": authenticated, + "rate_limited": is_rate_limited, + "rate_limit": rate_limit_info, + "post_ban_cooldown": cooldown_remaining if cooldown_remaining > 0 else None, + }) + _spotify_status_timestamp = current_time + except Exception as exc: + logger.debug("Spotify status refresh failed: %s", exc) + + with _status_lock: + return dict(_spotify_status_cache) + + +def get_status_snapshot(spotify_client: Optional[Any] = None) -> Dict[str, Any]: + """Return the combined metadata-provider status snapshot.""" + return { + "metadata_source": get_metadata_source_status(), + "spotify": get_spotify_status(spotify_client=spotify_client), + } diff --git a/web_server.py b/web_server.py index ecb19d5c..42845bb2 100644 --- a/web_server.py +++ b/web_server.py @@ -103,6 +103,10 @@ from core.metadata.registry import ( get_spotify_disconnect_source, register_runtime_clients, ) +from core.metadata.status import ( + get_status_snapshot as get_metadata_status_snapshot, + invalidate_metadata_status_caches, +) from core.imports.context import ( get_import_clean_album, get_import_clean_title, @@ -811,34 +815,14 @@ _idle_since = {} _IDLE_GRACE_SECONDS = 5 _status_cache = { - 'metadata_source': {'connected': False, 'response_time': 0, 'source': 'itunes'}, 'media_server': {'connected': False, 'response_time': 0, 'type': None}, 'soulseek': {'connected': False, 'response_time': 0}, } _status_cache_timestamps: dict[str, float] = { - 'metadata_source': 0, 'media_server': 0, 'soulseek': 0, } STATUS_CACHE_TTL = 120 -SPOTIFY_STATUS_TTL_ACTIVE = 15 -SPOTIFY_STATUS_TTL_IDLE = 300 - -_spotify_status_cache = { - 'connected': False, - 'authenticated': False, - 'rate_limited': False, - 'rate_limit': None, - 'post_ban_cooldown': None, -} -_spotify_status_timestamp = 0.0 - - -def _invalidate_metadata_status_caches(): - """Mark the metadata-source and Spotify status snapshots stale.""" - global _spotify_status_timestamp - _status_cache_timestamps['metadata_source'] = 0 - _spotify_status_timestamp = 0 dev_mode_enabled = False _hydrabase_ws = None @@ -3464,11 +3448,7 @@ def get_status(): current_time = time.time() active_server = config_manager.get_active_media_server() - # Test primary metadata provider and Spotify separately - if current_time - _status_cache_timestamps['metadata_source'] > STATUS_CACHE_TTL: - _status_cache['metadata_source'] = metadata_registry.get_primary_source_status() - _status_cache_timestamps['metadata_source'] = current_time - # else: use cached value + metadata_status = get_metadata_status_snapshot(spotify_client=spotify_client) # Test media server - use EXISTING instances (they have internal caching) # Media server clients already cache connection checks internally @@ -3542,8 +3522,8 @@ def get_status(): active_dl_count += 1 status_data = { - 'metadata_source': _status_cache['metadata_source'], - 'spotify': _build_spotify_status_payload(), + 'metadata_source': metadata_status['metadata_source'], + 'spotify': metadata_status['spotify'], 'media_server': _status_cache['media_server'], 'soulseek': _status_cache['soulseek'], 'active_media_server': active_server, @@ -4173,7 +4153,7 @@ def handle_settings(): if tidal_enrichment_worker: tidal_enrichment_worker.client = tidal_client # Invalidate status cache so next poll reflects new settings (e.g. fallback source change) - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() logger.info("Service clients re-initialized with new settings.") return jsonify({"success": True, "message": "Settings saved successfully."}) except Exception as e: @@ -4790,7 +4770,7 @@ def test_connection_endpoint(): if success: current_time = time.time() if service == 'spotify': - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() logger.info("Updated Spotify status cache after successful test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True @@ -4954,7 +4934,7 @@ def test_dashboard_connection_endpoint(): if success: current_time = time.time() if service == 'spotify': - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() logger.info("Updated Spotify status cache after successful dashboard test") elif service in ['plex', 'jellyfin', 'navidrome', 'soulsync']: _status_cache['media_server']['connected'] = True @@ -5833,7 +5813,7 @@ def spotify_callback(): return _spotify_auth_result_page("Your personal Spotify account is now connected. You can close this window.", authenticated=True) if profile_client: profile_client._invalidate_auth_cache() - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() add_activity_item("", "Spotify Auth Warning", f"Profile {profile_id_from_state} completed OAuth but Spotify did not confirm an authenticated session", "Now") return _spotify_auth_result_page( "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session for this profile. You can close this window and try Authenticate again.", @@ -5869,7 +5849,7 @@ def spotify_callback(): _clear_rate_limit() spotify_client._invalidate_auth_cache() # Invalidate status cache so next poll picks up the new connection - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() # Refresh enrichment worker's client so it picks up new auth if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): spotify_enrichment_worker.client.reload_config() @@ -5879,7 +5859,7 @@ def spotify_callback(): else: logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed") spotify_client._invalidate_auth_cache() - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") return _spotify_auth_result_page( "Spotify authorization completed, but SoulSync could not confirm an authenticated Spotify session. You can close this window and try Authenticate again.", @@ -5908,7 +5888,7 @@ def spotify_disconnect(): source_label = get_metadata_source_label(active_source) if configured_source == 'spotify': config_manager.set('metadata.fallback_source', active_source) - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now") return jsonify({ 'success': True, @@ -32043,7 +32023,7 @@ def start_oauth_callback_servers(): _clear_rate_limit() spotify_client._invalidate_auth_cache() # Invalidate status cache so next poll picks up the new connection - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() # Refresh enrichment worker's client so it picks up new auth if spotify_enrichment_worker and hasattr(spotify_enrichment_worker, 'client'): spotify_enrichment_worker.client.reload_config() @@ -32056,7 +32036,7 @@ def start_oauth_callback_servers(): else: _oauth_logger.warning("Spotify token exchange succeeded but authentication validation failed") spotify_client._invalidate_auth_cache() - _invalidate_metadata_status_caches() + invalidate_metadata_status_caches() add_activity_item("", "Spotify Auth Warning", "OAuth completed, but Spotify did not confirm an authenticated session", "Now") self.send_response(200) self.send_header('Content-type', 'text/html') @@ -34020,8 +34000,7 @@ def _build_status_payload(): download_mode = config_manager.get('download_source.mode', 'hybrid') soulseek_data = dict(_status_cache.get('soulseek', {})) soulseek_data['source'] = download_mode - metadata_source_data = dict(_status_cache.get('metadata_source', {})) - spotify_data = _build_spotify_status_payload() + metadata_status = get_metadata_status_snapshot(spotify_client=spotify_client) # Count active downloads for nav badge active_dl_count = 0 @@ -34034,8 +34013,8 @@ def _build_status_payload(): pass return { - 'metadata_source': metadata_source_data, - 'spotify': spotify_data, + 'metadata_source': metadata_status['metadata_source'], + 'spotify': metadata_status['spotify'], 'media_server': _status_cache.get('media_server', {}), 'soulseek': soulseek_data, 'active_media_server': config_manager.get_active_media_server(), @@ -34043,38 +34022,6 @@ def _build_status_payload(): 'active_downloads': active_dl_count, } -def _build_spotify_status_payload(): - """Build a Spotify-specific status snapshot for auth and rate-limit state.""" - import time - - global _spotify_status_timestamp - current_time = time.time() - configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' - ttl = SPOTIFY_STATUS_TTL_ACTIVE if configured_source == 'spotify' else SPOTIFY_STATUS_TTL_IDLE - - if _spotify_status_timestamp and current_time - _spotify_status_timestamp <= ttl: - return dict(_spotify_status_cache) - - try: - # Always include fresh rate limit info because it can change independently of cache TTL. - is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False - rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None - cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 - authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False - - _spotify_status_cache.update({ - 'connected': authenticated, - 'authenticated': authenticated, - 'rate_limited': is_rate_limited, - 'rate_limit': rate_limit_info, - 'post_ban_cooldown': cooldown_remaining if cooldown_remaining > 0 else None, - }) - _spotify_status_timestamp = current_time - except Exception: - pass - - return dict(_spotify_status_cache) - def _build_watchlist_count_payload(profile_id=1): """Build the same payload used by GET /api/watchlist/count.""" try: From 36131656ddc988f5324494715b5014184d246bbb Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 12:13:17 +0300 Subject: [PATCH 07/11] Make Spotify status updates event-driven - move Spotify status publishing onto auth, disconnect, and rate-limit transitions - keep dashboard and debug consumers on the shared cached snapshot - leave only the initial snapshot seed as a fallback probe --- core/debug_info.py | 25 +++---- core/metadata/__init__.py | 4 -- core/metadata/status.py | 140 +++++++++++++++++++++++++++----------- core/spotify_client.py | 109 +++++++++++++++++++++++++++++ web_server.py | 43 ++++++++---- 5 files changed, 253 insertions(+), 68 deletions(-) diff --git a/core/debug_info.py b/core/debug_info.py index 4ad83f1d..2af9d31a 100644 --- a/core/debug_info.py +++ b/core/debug_info.py @@ -300,23 +300,24 @@ def get_debug_info(): # API rate monitor — current calls/min, 24h totals, peaks, rate limit events try: from core.api_call_tracker import api_call_tracker + from core.metadata.status import get_spotify_status rates = api_call_tracker.get_all_rates() info['api_rates'] = rates # Rich 24h debug summary with peaks, totals, per-endpoint breakdown, events info['api_debug_summary'] = api_call_tracker.get_debug_summary() # Spotify rate limit details - if spotify_client: - rl_info = spotify_client.get_rate_limit_info() - if rl_info: - info['spotify_rate_limit'] = { - 'active': True, - 'remaining_seconds': rl_info.get('remaining_seconds', 0), - 'retry_after': rl_info.get('retry_after', 0), - 'endpoint': rl_info.get('endpoint', ''), - 'expires_at': rl_info.get('expires_at', ''), - } - else: - info['spotify_rate_limit'] = {'active': False} + spotify_status = get_spotify_status(spotify_client=spotify_client) + rl_info = spotify_status.get('rate_limit') + if spotify_status.get('rate_limited') and rl_info: + info['spotify_rate_limit'] = { + 'active': True, + 'remaining_seconds': rl_info.get('remaining_seconds', 0), + 'retry_after': rl_info.get('retry_after', 0), + 'endpoint': rl_info.get('endpoint', ''), + 'expires_at': rl_info.get('expires_at', ''), + } + else: + info['spotify_rate_limit'] = {'active': False} except Exception: info['api_rates'] = {} info['api_debug_summary'] = {} diff --git a/core/metadata/__init__.py b/core/metadata/__init__.py index 6cecfefc..7c0e92b2 100644 --- a/core/metadata/__init__.py +++ b/core/metadata/__init__.py @@ -42,8 +42,6 @@ from core.metadata.registry import ( ) from core.metadata.status import ( METADATA_SOURCE_STATUS_TTL, - SPOTIFY_STATUS_TTL_ACTIVE, - SPOTIFY_STATUS_TTL_IDLE, get_metadata_source_status, get_spotify_status, get_status_snapshot, @@ -98,7 +96,5 @@ __all__ = [ "register_profile_spotify_credentials_provider", "register_runtime_clients", "resolve_album_reference", - "SPOTIFY_STATUS_TTL_ACTIVE", - "SPOTIFY_STATUS_TTL_IDLE", "invalidate_metadata_status_caches", ] diff --git a/core/metadata/status.py b/core/metadata/status.py index 0e7c4bbf..9e3fdffb 100644 --- a/core/metadata/status.py +++ b/core/metadata/status.py @@ -6,7 +6,6 @@ import threading import time from typing import Any, Dict, Optional -from config.settings import config_manager from utils.logging_config import get_logger from core.metadata.registry import get_primary_source_status @@ -14,8 +13,8 @@ from core.metadata.registry import get_primary_source_status logger = get_logger("metadata.status") METADATA_SOURCE_STATUS_TTL = 120 -SPOTIFY_STATUS_TTL_ACTIVE = 15 -SPOTIFY_STATUS_TTL_IDLE = 300 + +_UNSET = object() _status_lock = threading.RLock() _metadata_source_status_cache: Dict[str, Any] = { @@ -32,21 +31,80 @@ _spotify_status_cache: Dict[str, Any] = { "post_ban_cooldown": None, } _spotify_status_timestamp = 0.0 - - -def _get_config_value(key: str, default: Any = None) -> Any: - try: - return config_manager.get(key, default) - except Exception: - return default +_spotify_status_initialized = False +_spotify_rate_limit_expires_at = 0.0 +_spotify_post_ban_cooldown_expires_at = 0.0 def invalidate_metadata_status_caches() -> None: - """Mark the cached metadata-source and Spotify status snapshots stale.""" - global _metadata_source_status_timestamp, _spotify_status_timestamp + """Mark the cached metadata-source snapshot stale.""" + global _metadata_source_status_timestamp with _status_lock: _metadata_source_status_timestamp = 0.0 - _spotify_status_timestamp = 0.0 + + +def publish_spotify_status( + *, + connected: Any = _UNSET, + authenticated: Any = _UNSET, + rate_limited: Any = _UNSET, + rate_limit: Any = _UNSET, + post_ban_cooldown: Any = _UNSET, +) -> Dict[str, Any]: + """Update the cached Spotify status snapshot from an event.""" + global _spotify_status_timestamp, _spotify_status_initialized + global _spotify_rate_limit_expires_at, _spotify_post_ban_cooldown_expires_at + + with _status_lock: + if connected is not _UNSET: + _spotify_status_cache["connected"] = connected + if authenticated is not _UNSET: + _spotify_status_cache["authenticated"] = authenticated + if rate_limited is not _UNSET: + _spotify_status_cache["rate_limited"] = rate_limited + if rate_limit is not _UNSET: + _spotify_status_cache["rate_limit"] = rate_limit + if rate_limit and isinstance(rate_limit, dict): + _spotify_rate_limit_expires_at = float(rate_limit.get("expires_at") or 0.0) + else: + _spotify_rate_limit_expires_at = 0.0 + if post_ban_cooldown is not _UNSET: + _spotify_status_cache["post_ban_cooldown"] = post_ban_cooldown + if post_ban_cooldown is not None: + _spotify_post_ban_cooldown_expires_at = time.time() + max(0, float(post_ban_cooldown)) + else: + _spotify_post_ban_cooldown_expires_at = 0.0 + _spotify_status_timestamp = time.time() + _spotify_status_initialized = True + _normalize_spotify_status_locked(_spotify_status_timestamp) + return dict(_spotify_status_cache) + + +def refresh_spotify_status_from_client(spotify_client: Optional[Any]) -> Dict[str, Any]: + """Probe Spotify once to seed the cache when no event has populated it yet.""" + if spotify_client is None: + with _status_lock: + return dict(_spotify_status_cache) + + try: + is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False + rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None + cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 + authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False + except Exception as exc: + logger.debug("Spotify status probe failed: %s", exc) + authenticated = False + is_rate_limited = False + rate_limit_info = None + cooldown_remaining = 0 + + return publish_spotify_status( + connected=authenticated, + authenticated=authenticated, + rate_limited=is_rate_limited, + rate_limit=rate_limit_info, + post_ban_cooldown=cooldown_remaining if cooldown_remaining > 0 else None, + ) def get_metadata_source_status() -> Dict[str, Any]: @@ -75,36 +133,42 @@ def get_metadata_source_status() -> Dict[str, Any]: def get_spotify_status(spotify_client: Optional[Any] = None) -> Dict[str, Any]: """Return a cached Spotify-specific status snapshot.""" - global _spotify_status_timestamp - - current_time = time.time() - configured_source = _get_config_value("metadata.fallback_source", "deezer") or "deezer" - ttl = SPOTIFY_STATUS_TTL_ACTIVE if configured_source == "spotify" else SPOTIFY_STATUS_TTL_IDLE - with _status_lock: - if _spotify_status_timestamp and current_time - _spotify_status_timestamp <= ttl: + if _spotify_status_initialized: + _normalize_spotify_status_locked(time.time()) return dict(_spotify_status_cache) - try: - is_rate_limited = spotify_client.is_rate_limited() if spotify_client else False - rate_limit_info = spotify_client.get_rate_limit_info() if (spotify_client and is_rate_limited) else None - cooldown_remaining = spotify_client.get_post_ban_cooldown_remaining() if spotify_client else 0 - authenticated = spotify_client.is_spotify_authenticated() if spotify_client else False + return refresh_spotify_status_from_client(spotify_client) - with _status_lock: - _spotify_status_cache.update({ - "connected": authenticated, - "authenticated": authenticated, - "rate_limited": is_rate_limited, - "rate_limit": rate_limit_info, - "post_ban_cooldown": cooldown_remaining if cooldown_remaining > 0 else None, - }) - _spotify_status_timestamp = current_time - except Exception as exc: - logger.debug("Spotify status refresh failed: %s", exc) - with _status_lock: - return dict(_spotify_status_cache) +def _normalize_spotify_status_locked(current_time: float) -> None: + """Update derived Spotify status fields and clear expired ban state.""" + global _spotify_rate_limit_expires_at, _spotify_post_ban_cooldown_expires_at + + rate_limit = _spotify_status_cache.get("rate_limit") + if _spotify_status_cache.get("rate_limited") and rate_limit and isinstance(rate_limit, dict): + expires_at = float(rate_limit.get("expires_at") or 0.0) + if expires_at > 0: + remaining = int(max(0, expires_at - current_time)) + if remaining > 0: + _spotify_status_cache["rate_limit"] = {**rate_limit, "remaining_seconds": remaining} + _spotify_rate_limit_expires_at = expires_at + else: + _spotify_status_cache["rate_limited"] = False + _spotify_status_cache["rate_limit"] = None + _spotify_rate_limit_expires_at = 0.0 + elif _spotify_rate_limit_expires_at and current_time >= _spotify_rate_limit_expires_at: + _spotify_status_cache["rate_limited"] = False + _spotify_status_cache["rate_limit"] = None + _spotify_rate_limit_expires_at = 0.0 + + if _spotify_post_ban_cooldown_expires_at > 0: + remaining = int(max(0, _spotify_post_ban_cooldown_expires_at - current_time)) + if remaining > 0: + _spotify_status_cache["post_ban_cooldown"] = remaining + else: + _spotify_status_cache["post_ban_cooldown"] = None + _spotify_post_ban_cooldown_expires_at = 0.0 def get_status_snapshot(spotify_client: Optional[Any] = None) -> Dict[str, Any]: diff --git a/core/spotify_client.py b/core/spotify_client.py index 50979bd5..124aa7b5 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -138,6 +138,18 @@ def _set_global_rate_limit(retry_after_seconds, endpoint_name, has_real_header=F ) except Exception: pass + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + connected=False, + authenticated=True, + rate_limited=True, + rate_limit=_get_rate_limit_info(), + post_ban_cooldown=_get_post_ban_cooldown_remaining() or None, + ) + except Exception: + pass def _is_globally_rate_limited(): @@ -210,6 +222,16 @@ def _clear_rate_limit(): _rate_limit_hit_count = 0 _rate_limit_first_hit = 0 logger.info("Global rate limit ban cleared (including post-ban cooldown)") + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + rate_limited=False, + rate_limit=None, + post_ban_cooldown=None, + ) + except Exception: + pass def _detect_and_set_rate_limit(exception, endpoint_name="unknown"): @@ -603,13 +625,38 @@ class SpotifyClient: """Check if Spotify client is specifically authenticated (not just iTunes fallback). Results are cached for 60 seconds to avoid excessive API calls. During rate limit bans and post-ban cooldown, returns False without making API calls.""" + rate_limited_state = False if self.sp is None: + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + connected=False, + authenticated=False, + rate_limited=_is_globally_rate_limited(), + rate_limit=_get_rate_limit_info(), + post_ban_cooldown=_get_post_ban_cooldown_remaining() or None, + ) + except Exception: + pass return False # If globally rate limited, report as NOT authenticated so callers # skip Spotify and fall through to iTunes fallback naturally. # This prevents any API calls that could extend the ban. if _is_globally_rate_limited(): + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + connected=False, + authenticated=True, + rate_limited=True, + rate_limit=_get_rate_limit_info(), + post_ban_cooldown=_get_post_ban_cooldown_remaining() or None, + ) + except Exception: + pass return False # Post-ban cooldown: after a ban expires, don't probe Spotify immediately. @@ -618,11 +665,35 @@ class SpotifyClient: if _is_in_post_ban_cooldown(): remaining = _get_post_ban_cooldown_remaining() logger.debug(f"Post-ban cooldown active ({remaining}s left), skipping auth probe") + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + connected=False, + authenticated=True, + rate_limited=False, + rate_limit=None, + post_ban_cooldown=remaining or None, + ) + except Exception: + pass 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: + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + connected=self._auth_cached_result, + authenticated=self._auth_cached_result, + rate_limited=False, + rate_limit=None, + post_ban_cooldown=None, + ) + except Exception: + pass return self._auth_cached_result # Cache miss — make API call outside the lock. @@ -637,6 +708,18 @@ class SpotifyClient: with self._auth_cache_lock: self._auth_cached_result = False self._auth_cache_time = time.time() + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + connected=False, + authenticated=False, + rate_limited=False, + rate_limit=None, + post_ban_cooldown=None, + ) + except Exception: + pass return False except Exception: pass @@ -668,6 +751,7 @@ class SpotifyClient: ban_duration = max(delay, _BASE_UNKNOWN_BAN) _set_global_rate_limit(ban_duration, 'is_spotify_authenticated', has_real_header=has_real_header) logger.warning(f"Auth probe rate limited — activating {ban_duration}s global ban") + rate_limited_state = True result = True else: logger.debug(f"Spotify authentication check failed: {e}") @@ -677,6 +761,19 @@ class SpotifyClient: self._auth_cached_result = result self._auth_cache_time = time.time() + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + connected=result, + authenticated=result, + rate_limited=rate_limited_state, + rate_limit=_get_rate_limit_info() if rate_limited_state else None, + post_ban_cooldown=None, + ) + except Exception: + pass + return result def disconnect(self): @@ -686,6 +783,18 @@ class SpotifyClient: self.user_id = None self._invalidate_auth_cache() _clear_rate_limit() + try: + from core.metadata.status import publish_spotify_status + + publish_spotify_status( + connected=False, + authenticated=False, + rate_limited=False, + rate_limit=None, + post_ban_cooldown=None, + ) + except Exception: + pass cache_path = 'config/.spotify_cache' try: diff --git a/web_server.py b/web_server.py index 42845bb2..6a8b79ea 100644 --- a/web_server.py +++ b/web_server.py @@ -105,6 +105,8 @@ from core.metadata.registry import ( ) from core.metadata.status import ( get_status_snapshot as get_metadata_status_snapshot, + get_spotify_status, + publish_spotify_status, invalidate_metadata_status_caches, ) from core.imports.context import ( @@ -4152,6 +4154,14 @@ def handle_settings(): genius_worker._init_client() if tidal_enrichment_worker: tidal_enrichment_worker.client = tidal_client + if 'spotify' in new_settings: + publish_spotify_status( + connected=False, + authenticated=False, + rate_limited=False, + rate_limit=None, + post_ban_cooldown=None, + ) # Invalidate status cache so next poll reflects new settings (e.g. fallback source change) invalidate_metadata_status_caches() logger.info("Service clients re-initialized with new settings.") @@ -5906,15 +5916,20 @@ def spotify_disconnect(): def spotify_rate_limit_status(): """Get Spotify rate limit ban details""" try: - info = spotify_client.get_rate_limit_info() + info = get_spotify_status(spotify_client=spotify_client) + rate_limit_info = info.get('rate_limit') if info: - return jsonify({ - 'rate_limited': True, - 'remaining_seconds': info['remaining_seconds'], - 'retry_after': info['retry_after'], - 'endpoint': info['endpoint'], - 'expires_at': info['expires_at'] - }) + payload = { + 'rate_limited': bool(info.get('rate_limited')), + } + if rate_limit_info: + payload.update({ + 'remaining_seconds': rate_limit_info.get('remaining_seconds', 0), + 'retry_after': rate_limit_info.get('retry_after'), + 'endpoint': rate_limit_info.get('endpoint'), + 'expires_at': rate_limit_info.get('expires_at'), + }) + return jsonify(payload) return jsonify({'rate_limited': False}) except Exception as e: logger.error(f"Error getting Spotify rate limit status: {e}") @@ -34378,12 +34393,12 @@ def _emit_rate_monitor_loop(): # Add Spotify rate limit state try: - if spotify_client: - rl_info = spotify_client.get_rate_limit_info() - if rl_info: - payload['spotify']['rate_limited'] = True - payload['spotify']['rl_remaining'] = rl_info.get('remaining_seconds', 0) - payload['spotify']['rl_endpoint'] = rl_info.get('endpoint', '') + spotify_status = get_spotify_status(spotify_client=spotify_client) + rl_info = spotify_status.get('rate_limit') + if spotify_status.get('rate_limited') and rl_info: + payload['spotify']['rate_limited'] = True + payload['spotify']['rl_remaining'] = rl_info.get('remaining_seconds', 0) + payload['spotify']['rl_endpoint'] = rl_info.get('endpoint', '') except Exception: pass From a2176af00eae7dcc0b89ec4b88175c4a77fc66b1 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 2 May 2026 15:09:44 +0300 Subject: [PATCH 08/11] Rename metadata source status selectors - Switch the dashboard/sidebar service-status card from spotify-branded ids to metadata-source ids - Update the shared status helpers to target the renamed metadata-source card - Keep the actual Spotify auth and settings UI unchanged --- webui/index.html | 14 +++++++------- webui/static/core.js | 4 ++-- webui/static/helper.js | 12 ++++++------ webui/static/shared-helpers.js | 20 ++++++++++---------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/webui/index.html b/webui/index.html index 4e5d3afc..e3c1b192 100644 --- a/webui/index.html +++ b/webui/index.html @@ -279,9 +279,9 @@

Service Status

-
+
- Spotify + Spotify
@@ -616,14 +616,14 @@

Service Status

-
+
- Spotify + Spotify + id="metadata-source-status-indicator">●
-

Disconnected

-

Response: --

+

Disconnected

+

Response: --