- Redirect URI:{configured_uri}
- After authorizing, Spotify will redirect back automatically. Make sure this URL matches your Spotify Dashboard redirect URI.
-
-
After authentication completes, you can close this window and return to SoulSync.
-
-
- '''
- else:
- # redirect_uri points to the standalone callback server — show manual steps AND suggest switching
- import re as _re
- _port_match = _re.search(r':(\d+)/', configured_uri)
- callback_server_port = _port_match.group(1) if _port_match else str(os.environ.get('SOULSYNC_SPOTIFY_CALLBACK_PORT', '8888'))
- return f'''
-
-
-
-
-
-
Spotify Authentication (Remote/Docker)
+ if not (is_remote or is_docker):
+ return redirect(auth_url)
-
- Using a reverse proxy? Your redirect URI is set to {configured_uri}
- which uses port {callback_server_port}. If you're behind a reverse proxy (Caddy, Nginx, Traefik), change the
- redirect URI in SoulSync settings to use your proxy URL on the main port instead, e.g.:
- https://{host}/callback
- Then update the same URI in your Spotify Dashboard.
- This avoids the need for manual URL editing below.
-
+ if uses_main_port:
+ # The OAuth callback returns to the app itself, so there is no
+ # need to keep an intermediate page open.
+ return redirect(auth_url)
-
Step 1: Click the link below to authenticate with Spotify
+ Using a reverse proxy? Your redirect URI is set to {configured_uri}
+ which uses port {callback_server_port}. If you're behind a reverse proxy (Caddy, Nginx, Traefik), change the
+ redirect URI in SoulSync settings to use your proxy URL on the main port instead, e.g.:
+ https://{host}/callback
+ Then update the same URI in your Spotify Dashboard.
+ This avoids the need for manual URL editing below.
+
+
+
Step 1: Click the link below to authenticate with Spotify
", 500
+def _spotify_auth_result_page(detail_text: str, authenticated: bool = True) -> str:
+ """Return the post-auth page and notify the opener."""
+ title = "Spotify Authentication Successful" if authenticated else "Spotify Authentication Completed"
+ heading = title
+ close_script = """
+ setTimeout(() => window.close(), 300);
+ """ if authenticated else """
+ const closeBtn = document.getElementById('close-window-btn');
+ if (closeBtn) {
+ closeBtn.addEventListener('click', () => window.close());
+ }
+ """
+ return f"""
+
+
+
+ {title}
+
+
+
{heading}
+
{detail_text}
+ {'' if not authenticated else ''}
+
+
+"""
+
+
@app.route('/callback')
def spotify_callback():
"""
@@ -5811,10 +5844,22 @@ def spotify_callback():
)
token_info = auth_manager.get_access_token(auth_code)
if token_info:
- # Invalidate cached profile client so it gets recreated with new tokens
metadata_registry.clear_cached_profile_spotify_client(profile_id_from_state)
- add_activity_item("", "Spotify Auth Complete", f"Profile {profile_id_from_state} authenticated with Spotify", "Now")
- return "
Spotify Authentication Successful!
Your personal Spotify account is now connected. You can close this window.
"
+ profile_client = metadata_registry.get_spotify_client_for_profile(profile_id_from_state)
+ profile_authenticated = bool(profile_client and profile_client.is_spotify_authenticated())
+ if profile_authenticated:
+ if profile_client:
+ profile_client._invalidate_auth_cache()
+ add_activity_item("", "Spotify Auth Complete", f"Profile {profile_id_from_state} authenticated with Spotify", "Now")
+ 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
+ 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.",
+ authenticated=False,
+ )
else:
raise Exception("Failed to exchange authorization code for access token")
@@ -5851,9 +5896,16 @@ def spotify_callback():
spotify_enrichment_worker.client.reload_config()
spotify_enrichment_worker.client._invalidate_auth_cache()
add_activity_item("", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now")
- return "
Spotify Authentication Successful!
You can close this window.
"
+ return _spotify_auth_result_page("You can close this window.", authenticated=True)
else:
- raise Exception("Token exchange succeeded but authentication validation failed")
+ logger.warning("Spotify OAuth token exchange succeeded but authentication validation failed")
+ spotify_client._invalidate_auth_cache()
+ _status_cache_timestamps['spotify'] = 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.",
+ authenticated=False,
+ )
else:
raise Exception("Failed to exchange authorization code for access token")
except Exception as e:
@@ -5864,26 +5916,37 @@ def spotify_callback():
@app.route('/api/spotify/disconnect', methods=['POST'])
def spotify_disconnect():
- """Disconnect Spotify and fall back to iTunes/Apple Music"""
+ """Disconnect Spotify and keep using the active primary metadata source."""
global spotify_client
try:
+ configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
# Pause enrichment worker before disconnecting to prevent it from hammering API
if spotify_enrichment_worker:
spotify_enrichment_worker.pause()
spotify_client.disconnect()
# Immediately update status cache so UI reflects the change
- fallback_src = _get_metadata_fallback_source()
+ active_source = get_spotify_disconnect_source(configured_source)
+ source_label = get_metadata_source_label(active_source)
+ if configured_source == 'spotify':
+ config_manager.set('metadata.fallback_source', active_source)
_status_cache['spotify'] = {
- 'connected': True, # Fallback source is always available
+ 'connected': False,
+ 'authenticated': False,
'response_time': 0,
- 'source': fallback_src,
+ 'source': active_source,
'rate_limited': False,
- 'rate_limit': None
+ 'rate_limit': None,
+ 'post_ban_cooldown': None
}
_status_cache_timestamps['spotify'] = time.time()
- fallback_label = 'Deezer' if fallback_src == 'deezer' else 'Discogs' if fallback_src == 'discogs' else 'iTunes'
- add_activity_item("", "Spotify Disconnected", f"Switched to {fallback_label} metadata source", "Now")
- return jsonify({'success': True, 'message': f'Spotify disconnected. Now using {fallback_label}.'})
+ add_activity_item("", "Spotify Disconnected", f"Using {source_label} for metadata", "Now")
+ return jsonify({
+ 'success': True,
+ 'message': f'Spotify disconnected. Using {source_label} for metadata.',
+ 'source': active_source,
+ 'authenticated': False,
+ 'primary_source_changed': configured_source == 'spotify'
+ })
except Exception as e:
logger.error(f"Error disconnecting Spotify: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@@ -31986,9 +32049,19 @@ def start_oauth_callback_servers():
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
- self.wfile.write(b'
Spotify Authentication Successful!
You can close this window.
')
+ self.wfile.write(_spotify_auth_result_page("You can close this window.", authenticated=True).encode("utf-8"))
else:
- raise Exception("Token exchange succeeded but authentication validation failed")
+ _oauth_logger.warning("Spotify token exchange succeeded but authentication validation failed")
+ spotify_client._invalidate_auth_cache()
+ _status_cache_timestamps['spotify'] = 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')
+ self.end_headers()
+ self.wfile.write(_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.",
+ authenticated=False,
+ ).encode("utf-8"))
else:
raise Exception("Failed to exchange authorization code for access token")
except Exception as e:
diff --git a/webui/index.html b/webui/index.html
index 87fe7938..4652f7e6 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3693,7 +3693,7 @@
Metadata Source
-
+
-
The primary source for artist, album, and track metadata. Spotify requires authentication below. Discogs requires a personal token.
+
Choose the primary source for artist, album, and track metadata. Spotify can only be selected while an active Spotify session exists. Discogs requires a personal token.
You can wait for the ban to expire (the app uses Apple Music in the meantime) or disconnect Spotify to clear the ban immediately.
+
While rate limiting is active, Spotify-specific features are unavailable. You can wait for the ban to expire or disconnect Spotify to clear it immediately.
diff --git a/webui/static/core.js b/webui/static/core.js
index 32a75406..3a23cb53 100644
--- a/webui/static/core.js
+++ b/webui/static/core.js
@@ -473,6 +473,16 @@ function handleServiceStatusUpdate(data) {
// Cache for library status card
_lastServiceStatus = data;
+ if (typeof syncSpotifySettingsAuthState === 'function') {
+ syncSpotifySettingsAuthState(data?.spotify || null);
+ }
+ if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
+ syncPrimaryMetadataSourceAvailability(data?.spotify || null);
+ }
+ if (typeof sanitizeMetadataSourceSelection === 'function') {
+ sanitizeMetadataSourceSelection({ quiet: true });
+ }
+
// Same logic as fetchAndUpdateServiceStatus response handler
updateServiceStatus('spotify', data.spotify);
updateServiceStatus('media-server', data.media_server);
@@ -876,4 +886,3 @@ let _lastServiceStatus = null;
let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden)
// ===============================
-
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 46994799..6be9cfb0 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -2979,8 +2979,8 @@ async function _checkSetupStatus() {
const resp = await fetch('/status');
if (resp.ok) {
const data = await resp.json();
- // Metadata source: spotify.connected is always true (iTunes fallback), check .source
- if (data.spotify?.connected && data.spotify?.source) {
+ // Metadata source is available when status reports a source.
+ if (data.spotify?.source) {
results['metadata-source'] = results['metadata-source'] || Date.now();
_markSetupComplete('metadata-source');
}
diff --git a/webui/static/init.js b/webui/static/init.js
index 2680518a..6b3b11eb 100644
--- a/webui/static/init.js
+++ b/webui/static/init.js
@@ -1970,6 +1970,9 @@ function initApp() {
initExpandedPlayer();
initializeSyncPage();
initializeWatchlist();
+ if (typeof initializeSpotifyAuthCompletionListener === 'function') {
+ initializeSpotifyAuthCompletionListener();
+ }
// Initialize WebSocket connection (falls back to HTTP polling if unavailable)
@@ -2371,4 +2374,3 @@ async function loadPageData(pageId) {
// Old updateStatusIndicator function removed - replaced by updateSidebarServiceStatus
// ===============================
-
diff --git a/webui/static/settings.js b/webui/static/settings.js
index 4f2ade67..dfa092bb 100644
--- a/webui/static/settings.js
+++ b/webui/static/settings.js
@@ -50,6 +50,95 @@ function handleManualSaveClick() {
saveSettings(false);
}
+function syncMetadataSourceSelection(source) {
+ const select = document.getElementById('metadata-fallback-source');
+ if (!select || !source) return;
+ const option = select.querySelector(`option[value="${source}"]`);
+ if (option) select.value = source;
+ select.dataset.lastValidSource = source;
+}
+
+function _isMetadataSourceSelectable(source) {
+ if (source === 'spotify') {
+ return _lastServiceStatus?.spotify?.authenticated === true;
+ }
+ if (source === 'discogs') {
+ const token = document.getElementById('discogs-token');
+ return !!token?.value?.trim();
+ }
+ return true;
+}
+
+function _metadataSourceFallback(source) {
+ if (source === 'spotify') return 'deezer';
+ if (source === 'discogs') return 'itunes';
+ return 'itunes';
+}
+
+function focusServiceSettingsSection(service, message) {
+ const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
+ if (!card) return;
+
+ const header = card.querySelector('.stg-service-header');
+ if (!card.classList.contains('expanded') && header) {
+ toggleStgService(header);
+ }
+
+ card.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+ const firstControl = card.querySelector('input, button');
+ if (firstControl) {
+ firstControl.focus({ preventScroll: true });
+ }
+
+ if (message) {
+ showToast(message, 'warning');
+ }
+}
+
+function sanitizeMetadataSourceSelection({ quiet = true } = {}) {
+ const select = document.getElementById('metadata-fallback-source');
+ if (!select) return false;
+
+ const selectedSource = select.value || 'itunes';
+ if (_isMetadataSourceSelectable(selectedSource)) {
+ select.dataset.lastValidSource = selectedSource;
+ return false;
+ }
+
+ const lastValid = select.dataset.lastValidSource;
+ const fallbackSource = lastValid && lastValid !== selectedSource && _isMetadataSourceSelectable(lastValid)
+ ? lastValid
+ : _metadataSourceFallback(selectedSource);
+
+ if (fallbackSource && fallbackSource !== selectedSource) {
+ select.value = fallbackSource;
+ }
+ select.dataset.lastValidSource = fallbackSource;
+
+ if (!quiet) {
+ const message = selectedSource === 'discogs'
+ ? 'Discogs requires a personal access token before it can be selected as the primary metadata source.'
+ : 'Spotify must be authenticated before it can be selected as the primary metadata source.';
+ focusServiceSettingsSection(selectedSource, message);
+ }
+
+ return true;
+}
+
+function handleMetadataSourceChange(event) {
+ const select = event.target;
+ if (!select || select.id !== 'metadata-fallback-source') return;
+
+ const selectedSource = select.value;
+ if (_isMetadataSourceSelectable(selectedSource)) {
+ select.dataset.lastValidSource = selectedSource;
+ return;
+ }
+
+ sanitizeMetadataSourceSelection({ quiet: false });
+}
+
function initializeSettings() {
// This function is called when the settings page is loaded.
// It attaches event listeners to all interactive elements on the page.
@@ -76,6 +165,20 @@ function initializeSettings() {
});
}
+ const metadataSourceSelect = document.getElementById('metadata-fallback-source');
+ if (metadataSourceSelect) {
+ metadataSourceSelect.addEventListener('change', handleMetadataSourceChange);
+ }
+ const discogsTokenInput = document.getElementById('discogs-token');
+ if (discogsTokenInput) {
+ discogsTokenInput.addEventListener('input', () => {
+ if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
+ syncPrimaryMetadataSourceAvailability(_lastServiceStatus?.spotify || null);
+ }
+ sanitizeMetadataSourceSelection({ quiet: true });
+ });
+ }
+
// Server toggle buttons
const plexToggle = document.getElementById('plex-toggle');
if (plexToggle) {
@@ -102,6 +205,16 @@ function initializeSettings() {
// Test connection buttons
// Test button event listeners removed - they use onclick attributes in HTML to avoid double firing
+
+ if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
+ syncPrimaryMetadataSourceAvailability(_lastServiceStatus?.spotify || null);
+ }
+ syncSpotifySettingsAuthState(_lastServiceStatus?.spotify || null);
+ syncMetadataSourceSelection(_lastServiceStatus?.spotify?.source);
+ sanitizeMetadataSourceSelection({ quiet: true });
+ if (metadataSourceSelect) {
+ metadataSourceSelect.dataset.lastValidSource = metadataSourceSelect.value;
+ }
}
function resetFileOrganizationTemplates() {
@@ -295,11 +408,37 @@ async function applyServiceStatusGradients() {
else header.appendChild(spinner);
}
});
+ syncSpotifySettingsAuthState(_lastServiceStatus?.spotify || null);
} catch (e) {
console.warn('[Settings Status] Failed to apply gradients:', e);
}
}
+function syncSpotifySettingsAuthState(statusData) {
+ if (!statusData) return;
+
+ const card = document.querySelector('#settings-page .stg-service[data-service="spotify"]');
+ if (!card) return;
+
+ const header = card.querySelector('.stg-service-header');
+ const dot = card.querySelector('.stg-service-dot');
+ if (!header && !dot) return;
+
+ const authenticated = statusData?.authenticated === true;
+ const rateLimited = !!(statusData?.rate_limited && statusData?.rate_limit);
+ const cooldown = !!(statusData?.post_ban_cooldown > 0);
+ const needsAttention = !authenticated || rateLimited || cooldown;
+
+ if (header) {
+ header.classList.toggle('status-configured', !needsAttention);
+ header.classList.toggle('status-missing', needsAttention);
+ }
+
+ if (dot) {
+ dot.style.color = needsAttention ? '#f1c40f' : '#1DB954';
+ }
+}
+
function _stgSetCheckingState(service, isChecking) {
const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`);
if (!card) return;
@@ -2400,6 +2539,25 @@ async function saveSettings(quiet = false) {
activeServer = 'soulsync';
}
+ const metadataSourceSelect = document.getElementById('metadata-fallback-source');
+ const discogsTokenInput = document.getElementById('discogs-token');
+ const discogsTokenPresent = !!discogsTokenInput?.value?.trim();
+ let metadataSource = metadataSourceSelect?.value || 'itunes';
+ const spotifySessionActive = _lastServiceStatus?.spotify?.authenticated === true;
+ if (metadataSource === 'spotify' && !spotifySessionActive) {
+ metadataSource = 'deezer';
+ if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
+ if (!quiet) {
+ showToast('Spotify is disconnected, so Deezer is used as the primary metadata source.', 'warning');
+ }
+ } else if (metadataSource === 'discogs' && !discogsTokenPresent) {
+ metadataSource = 'itunes';
+ if (metadataSourceSelect) metadataSourceSelect.value = metadataSource;
+ if (!quiet) {
+ showToast('Discogs requires a personal access token before it can be selected as the primary metadata source.', 'warning');
+ }
+ }
+
const settings = {
active_media_server: activeServer,
spotify: {
@@ -2472,7 +2630,7 @@ async function saveSettings(quiet = false) {
token: document.getElementById('discogs-token').value,
},
metadata: {
- fallback_source: document.getElementById('metadata-fallback-source').value || 'itunes'
+ fallback_source: metadataSource
},
hydrabase: {
url: document.getElementById('hydrabase-url').value,
@@ -3056,7 +3214,7 @@ async function authenticateSpotify() {
// Save settings first to ensure client_id/client_secret are persisted
await saveSettings();
showToast('Spotify authentication started', 'success');
- window.open('/auth/spotify', '_blank');
+ window._spotifyAuthWindow = window.open('/auth/spotify', '_blank');
} catch (error) {
console.error('Error authenticating Spotify:', error);
showToast('Failed to start Spotify authentication', 'error', 'gs-connecting');
@@ -3066,8 +3224,10 @@ async function authenticateSpotify() {
}
async function disconnectSpotify() {
- const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source';
- if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: `Disconnect Spotify? The app will switch to ${fallbackName} for metadata.` })) {
+ if (!await showConfirmDialog({
+ title: 'Disconnect Spotify',
+ message: 'Disconnect Spotify? Spotify-specific actions will stop until you reauthenticate.'
+ })) {
return;
}
try {
@@ -3075,7 +3235,8 @@ async function disconnectSpotify() {
const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
const data = await response.json();
if (data.success) {
- showToast(`Spotify disconnected. Now using ${fallbackName}.`, 'success');
+ showToast(data.message || 'Spotify disconnected.', 'success');
+ syncMetadataSourceSelection(data.source || 'deezer');
// Immediately refresh status to update UI
await fetchAndUpdateServiceStatus();
} else {
@@ -3089,29 +3250,6 @@ async function disconnectSpotify() {
}
}
-async function clearSpotifyCacheAndFallback() {
- const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source';
- if (!await showConfirmDialog({
- title: 'Clear Spotify Cache',
- message: `This will clear the Spotify token cache and switch metadata to ${fallbackName}. You can re-authenticate later.`
- })) return;
- try {
- showLoadingOverlay('Clearing Spotify cache...');
- const response = await fetch('/api/spotify/disconnect', { method: 'POST' });
- const data = await response.json();
- if (data.success) {
- showToast(data.message || `Switched to ${fallbackName}`, 'success');
- await fetchAndUpdateServiceStatus();
- } else {
- showToast(`Failed: ${data.error}`, 'error');
- }
- } catch (error) {
- showToast('Failed to clear Spotify cache', 'error');
- } finally {
- hideLoadingOverlay();
- }
-}
-
// ── Spotify Rate Limit Handling ───────────────────────────────────────────
let _spotifyRateLimitShown = false;
let _spotifyInCooldown = false;
@@ -3198,7 +3336,8 @@ async function disconnectSpotifyFromRateLimit() {
const data = await response.json();
if (data.success) {
_spotifyRateLimitShown = false;
- showToast(`Spotify disconnected. Now using ${currentMusicSourceName}.`, 'success');
+ showToast(data.message || 'Spotify disconnected.', 'success');
+ syncMetadataSourceSelection(data.source || 'deezer');
await fetchAndUpdateServiceStatus();
if (currentPage === 'discover') {
loadDiscoverPage();
@@ -3879,4 +4018,3 @@ function togglePathLock(pathType, btn) {
// ===============================
-
diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js
index 26b1c060..6fed9d88 100644
--- a/webui/static/shared-helpers.js
+++ b/webui/static/shared-helpers.js
@@ -3107,6 +3107,32 @@ async function _forceServiceStatusRefresh() {
}
}
+let _spotifyAuthCompletionListenerInstalled = false;
+window._spotifyAuthWindow = window._spotifyAuthWindow || null;
+
+function initializeSpotifyAuthCompletionListener() {
+ if (_spotifyAuthCompletionListenerInstalled) return;
+ _spotifyAuthCompletionListenerInstalled = true;
+
+ window.addEventListener('message', async event => {
+ if (!event.data || event.data.type !== 'spotify-auth-complete') return;
+ if (window._spotifyAuthWindow && event.source && event.source !== window._spotifyAuthWindow) return;
+
+ try {
+ window._spotifyAuthWindow = null;
+ await _forceServiceStatusRefresh();
+ if (event.data.authenticated === false) {
+ showToast(
+ event.data.detail || 'Spotify authorization completed, but no authenticated session was detected.',
+ 'warning'
+ );
+ }
+ } catch (error) {
+ console.warn('Could not refresh Spotify status after auth completion:', error);
+ }
+ });
+}
+
async function fetchAndUpdateServiceStatus() {
if (document.hidden) return; // Skip polling when tab is not visible
if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll
@@ -3119,6 +3145,16 @@ async function fetchAndUpdateServiceStatus() {
// Cache for library status card
_lastServiceStatus = data;
+ if (typeof syncSpotifySettingsAuthState === 'function') {
+ syncSpotifySettingsAuthState(data?.spotify || null);
+ }
+ if (typeof syncPrimaryMetadataSourceAvailability === 'function') {
+ syncPrimaryMetadataSourceAvailability(data?.spotify || null);
+ }
+ if (typeof sanitizeMetadataSourceSelection === 'function') {
+ sanitizeMetadataSourceSelection({ quiet: true });
+ }
+
// Update service status indicators and text (dashboard)
updateServiceStatus('spotify', data.spotify);
updateServiceStatus('media-server', data.media_server);
@@ -3160,28 +3196,109 @@ async function fetchAndUpdateServiceStatus() {
}
}
+function syncPrimaryMetadataSourceAvailability(statusData) {
+ const select = document.getElementById('metadata-fallback-source');
+ if (!select) return;
+ if (!statusData) return;
+
+ const spotifyOption = select.querySelector('option[value="spotify"]');
+ const discogsOption = select.querySelector('option[value="discogs"]');
+
+ const spotifyAvailable = statusData?.authenticated === true;
+ if (spotifyOption) {
+ spotifyOption.dataset.unavailable = spotifyAvailable ? 'false' : 'true';
+ spotifyOption.textContent = spotifyAvailable ? 'Spotify' : '🔒 Spotify';
+ spotifyOption.title = spotifyAvailable
+ ? 'Spotify'
+ : 'Spotify authentication is required before this source can be selected.';
+ }
+
+ if (discogsOption) {
+ const discogsToken = document.getElementById('discogs-token');
+ const discogsAvailable = !!discogsToken?.value?.trim();
+ discogsOption.dataset.unavailable = discogsAvailable ? 'false' : 'true';
+ discogsOption.textContent = discogsAvailable ? 'Discogs' : '🔒 Discogs';
+ discogsOption.title = discogsAvailable
+ ? 'Discogs'
+ : 'Discogs personal access token is required before this source can be selected.';
+ }
+}
+
+function getMetadataSourceLabel(source) {
+ if (source === 'deezer') return 'Deezer';
+ if (source === 'discogs') return 'Discogs';
+ if (source === 'itunes') return 'iTunes';
+ if (source === 'spotify') return 'Spotify';
+ 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');
+
+ if (rateLimited) {
+ const remaining = statusData.rate_limit?.remaining_seconds || 0;
+ return {
+ statusClass: 'rate-limited',
+ statusText: `Spotify paused \u2014 ${formatRateLimitDuration(remaining)}`,
+ dotClass: 'rate-limited',
+ dotTitle: `Spotify paused \u2014 ${formatRateLimitDuration(remaining)} remaining`,
+ sessionActive
+ };
+ }
+
+ if (cooldown) {
+ const remaining = statusData.post_ban_cooldown;
+ return {
+ statusClass: 'rate-limited',
+ statusText: `Spotify recovering \u2014 ${formatRateLimitDuration(remaining)}`,
+ dotClass: 'rate-limited',
+ dotTitle: `Spotify recovering \u2014 ${formatRateLimitDuration(remaining)} cooldown`,
+ sessionActive
+ };
+ }
+
+ if (statusData?.source && statusData.source !== 'spotify') {
+ return {
+ statusClass: 'connected',
+ statusText: sourceLabel,
+ dotClass: 'connected',
+ dotTitle: sourceLabel,
+ sessionActive
+ };
+ }
+
+ return {
+ statusClass: 'connected',
+ statusText: `Connected (${statusData?.response_time}ms)`,
+ dotClass: 'connected',
+ dotTitle: '',
+ sessionActive
+ };
+}
+
function updateServiceStatus(service, statusData) {
const indicator = document.getElementById(`${service}-status-indicator`);
const statusText = document.getElementById(`${service}-status-text`);
if (indicator && statusText) {
- if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
- indicator.className = 'service-card-indicator rate-limited';
- const remaining = statusData.rate_limited
- ? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)
- : formatRateLimitDuration(statusData.post_ban_cooldown);
- const phase = statusData.rate_limited ? 'paused' : 'recovering';
- const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'iTunes';
- statusText.textContent = `${fallbackLabel} (Spotify ${phase} \u2014 ${remaining})`;
- statusText.className = 'service-card-status-text rate-limited';
- } else if (statusData.connected) {
- indicator.className = 'service-card-indicator connected';
- statusText.textContent = `Connected (${statusData.response_time}ms)`;
- statusText.className = 'service-card-status-text connected';
+ if (service === 'spotify') {
+ const presentation = getSpotifyStatusPresentation(statusData || {});
+ indicator.className = `service-card-indicator ${presentation.statusClass}`;
+ statusText.textContent = presentation.statusText;
+ statusText.className = `service-card-status-text ${presentation.statusClass}`;
} else {
- indicator.className = 'service-card-indicator disconnected';
- statusText.textContent = 'Disconnected';
- statusText.className = 'service-card-status-text disconnected';
+ if (statusData.connected) {
+ indicator.className = 'service-card-indicator connected';
+ statusText.textContent = `Connected (${statusData.response_time}ms)`;
+ statusText.className = 'service-card-status-text connected';
+ } else {
+ indicator.className = 'service-card-indicator disconnected';
+ statusText.textContent = 'Disconnected';
+ statusText.className = 'service-card-status-text disconnected';
+ }
}
}
@@ -3189,16 +3306,23 @@ function updateServiceStatus(service, statusData) {
if (service === 'spotify' && statusData.source) {
const musicSourceTitleElement = document.getElementById('music-source-title');
if (musicSourceTitleElement) {
- const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
+ const sourceName = getMetadataSourceLabel(statusData.source);
musicSourceTitleElement.textContent = sourceName;
currentMusicSourceName = sourceName;
}
- // Show/hide Spotify disconnect button based on connection state
+ // Keep the Spotify action buttons aligned with the actual auth session.
+ const spotifySessionActive = getSpotifyStatusPresentation(statusData || {}).sessionActive;
+ const authBtn = document.querySelector('button[onclick="authenticateSpotify()"]');
const disconnectBtn = document.getElementById('spotify-disconnect-btn');
- if (disconnectBtn) {
- disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none';
+ if (authBtn) {
+ authBtn.style.display = spotifySessionActive ? 'none' : '';
}
+ if (disconnectBtn) {
+ disconnectBtn.style.display = spotifySessionActive ? '' : 'none';
+ }
+
+ syncPrimaryMetadataSourceAvailability(statusData);
}
// Update download source title on dashboard card
@@ -3217,16 +3341,12 @@ function updateSidebarServiceStatus(service, statusData) {
const nameElement = indicator.querySelector('.status-name');
if (dot) {
- if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) {
- dot.className = 'status-dot rate-limited';
- dot.title = statusData.rate_limited
- ? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining`
- : `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`;
- } else if (statusData.connected) {
- dot.className = 'status-dot connected';
- dot.title = '';
+ if (service === 'spotify') {
+ const presentation = getSpotifyStatusPresentation(statusData || {});
+ dot.className = `status-dot ${presentation.dotClass}`;
+ dot.title = presentation.dotTitle;
} else {
- dot.className = 'status-dot disconnected';
+ dot.className = statusData?.connected ? 'status-dot connected' : 'status-dot disconnected';
dot.title = '';
}
}
@@ -3244,7 +3364,7 @@ function updateSidebarServiceStatus(service, statusData) {
if (service === 'spotify' && statusData.source) {
const musicSourceNameElement = document.getElementById('music-source-name');
if (musicSourceNameElement) {
- const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes';
+ const sourceName = getMetadataSourceLabel(statusData.source);
musicSourceNameElement.textContent = sourceName;
}
}