diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py
index df2d9c18..47d2817c 100644
--- a/tests/test_credentials_endpoints.py
+++ b/tests/test_credentials_endpoints.py
@@ -181,3 +181,18 @@ def test_active_sources_nonadmin_readonly_and_blocked(client, nonadmin_profile):
sess['profile_id'] = nonadmin_profile
assert client.get('/api/profiles/me/active-sources').get_json()['editable'] is False
assert client.post('/api/profiles/active-sources', json={'metadata_source': 'deezer'}).status_code == 403
+
+
+def test_spotify_free_composite_roundtrips_like_settings(client):
+ # "Spotify (no auth)" is stored as fallback_source=spotify + spotify_free=true
+ # (the same composite the Settings page uses) — the modal must report it as
+ # active='spotify_free', not raw 'spotify'.
+ from config.settings import config_manager
+ assert client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify_free'}).get_json()['success']
+ assert config_manager.get('metadata.fallback_source') == 'spotify'
+ assert config_manager.get('metadata.spotify_free') is True
+ assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify_free'
+ # Switching to plain spotify clears the flag.
+ client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify'})
+ assert config_manager.get('metadata.spotify_free') is False
+ assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify'
diff --git a/web_server.py b/web_server.py
index 8e4d3eea..4f217f6c 100644
--- a/web_server.py
+++ b/web_server.py
@@ -25641,6 +25641,14 @@ def get_active_sources():
try:
mode = config_manager.get('download_source.mode', 'soulseek') or 'soulseek'
hybrid_order = config_manager.get('download_source.hybrid_order', []) or []
+ # "Spotify (no auth)" is a COMPOSITE the Settings page uses: it stores
+ # fallback_source='spotify' + metadata.spotify_free=true, NOT a literal
+ # 'spotify_free' fallback value. Mirror that mapping so the modal agrees
+ # with the Settings dropdown (settings.js _metaSel / save logic).
+ _fb = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer'
+ _free = config_manager.get('metadata.spotify_free', False)
+ meta_active = 'spotify_free' if (_fb == 'spotify' and _free) else _fb
+ meta_effective = 'spotify_free' if meta_active == 'spotify_free' else _get_metadata_fallback_source()
return jsonify({
'success': True,
'editable': get_current_profile_id() == 1, # admin writes the global default
@@ -25650,8 +25658,8 @@ def get_active_sources():
# fallback (e.g. configured 'spotify' but not authenticated →
# the app falls back). Surfacing both stops the modal disagreeing
# with the sidebar/Settings status.
- 'active': config_manager.get('metadata.fallback_source', 'deezer') or 'deezer',
- 'effective': _get_metadata_fallback_source(),
+ 'active': meta_active,
+ 'effective': meta_effective,
'options': [{'id': s, 'available': _qs_metadata_available(s)} for s in _QS_METADATA_SOURCES],
},
'server': {
@@ -25683,7 +25691,14 @@ def set_active_sources():
src = data['metadata_source']
if src not in _QS_METADATA_SOURCES:
return jsonify({'success': False, 'error': 'Unknown metadata source'}), 400
- config_manager.set('metadata.fallback_source', src)
+ # Same composite the Settings save uses: 'spotify_free' is stored as
+ # fallback_source='spotify' + metadata.spotify_free=true.
+ if src == 'spotify_free':
+ config_manager.set('metadata.fallback_source', 'spotify')
+ config_manager.set('metadata.spotify_free', True)
+ else:
+ config_manager.set('metadata.fallback_source', src)
+ config_manager.set('metadata.spotify_free', False)
invalidate_metadata_status_caches()
changed.append('metadata')
diff --git a/webui/static/service-switch.js b/webui/static/service-switch.js
index f046f4a2..15983ed1 100644
--- a/webui/static/service-switch.js
+++ b/webui/static/service-switch.js
@@ -173,12 +173,39 @@ function _ssCard({ logo, emoji, label, active, available, onclick, badge, brand
`;
}
+const _SS_TAB_BLURB = {
+ metadata: 'Where artist, album & track details come from.',
+ server: 'The library backend SoulSync reads and writes.',
+ download: 'Where SoulSync grabs tracks you don\'t have yet.',
+};
+
+function _ssHero(kind) {
+ const cur = _ssRailCurrent(kind);
+ if (!cur) return '';
+ const media = cur.logo
+ ? ``
+ : `${cur.emoji}`;
+ const eyebrow = kind === 'metadata' ? 'Active metadata source'
+ : kind === 'server' ? 'Active media server' : 'Active download source';
+ return `
+