diff --git a/core/metadata/registry.py b/core/metadata/registry.py index d1162b35..214aa3f6 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -300,9 +300,10 @@ def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = return source -def get_spotify_disconnect_source() -> str: +def get_spotify_disconnect_source(configured_source: Optional[str] = None) -> str: """Return the active metadata source after Spotify is disconnected.""" - source = get_primary_source() + source = configured_source if configured_source is not None else _get_config_value("metadata.fallback_source", "deezer") + source = source or "deezer" return "deezer" if source == "spotify" else source diff --git a/tests/metadata/test_metadata_registry.py b/tests/metadata/test_metadata_registry.py index ea3a33d0..e4701d99 100644 --- a/tests/metadata/test_metadata_registry.py +++ b/tests/metadata/test_metadata_registry.py @@ -6,16 +6,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from core.metadata import registry -def test_spotify_disconnect_source_uses_deezer_when_spotify_is_primary(monkeypatch): - monkeypatch.setattr(registry, "get_primary_source", lambda: "spotify") - - assert registry.get_spotify_disconnect_source() == "deezer" +def test_spotify_disconnect_source_uses_deezer_when_spotify_is_primary(): + assert registry.get_spotify_disconnect_source("spotify") == "deezer" -def test_spotify_disconnect_source_keeps_non_spotify_primary(monkeypatch): - monkeypatch.setattr(registry, "get_primary_source", lambda: "discogs") - - assert registry.get_spotify_disconnect_source() == "discogs" +def test_spotify_disconnect_source_keeps_non_spotify_primary(): + assert registry.get_spotify_disconnect_source("discogs") == "discogs" def test_metadata_source_label_maps_known_sources(): diff --git a/web_server.py b/web_server.py index d42781f8..1dfd3ad0 100644 --- a/web_server.py +++ b/web_server.py @@ -3426,7 +3426,7 @@ def get_status(): 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 = bool(spotify_client and getattr(spotify_client, 'sp', None) is not None) + spotify_session_active = 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' @@ -3447,7 +3447,7 @@ def get_status(): music_source = configured_source _status_cache['spotify'] = { - 'connected': True, # Always true — iTunes fallback is always available + 'connected': spotify_session_active, 'authenticated': spotify_session_active, 'response_time': round(spotify_response_time, 1), 'source': music_source, @@ -4777,7 +4777,7 @@ def test_connection_endpoint(): if success: current_time = time.time() if service == 'spotify': - spotify_session_active = bool(spotify_client and getattr(spotify_client, 'sp', None) is not None) + 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() @@ -4945,7 +4945,7 @@ def test_dashboard_connection_endpoint(): if success: current_time = time.time() if service == 'spotify': - spotify_session_active = bool(spotify_client and getattr(spotify_client, 'sp', None) is not None) + 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() @@ -5480,101 +5480,85 @@ def auth_spotify(): '127.0.0.1' not in configured_uri and 'localhost' not in configured_uri ) - if is_remote or is_docker: - # Show instructions for remote/docker access - if uses_main_port: - # redirect_uri already points to port 8008 or a custom domain — - # callback will come through the main Flask app, no manual steps needed - return f''' - -
- - - -Click the link below to authenticate with Spotify:
- -{configured_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''' - - - - - -{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}/callbackStep 1: Click the link below to authenticate with Spotify
- -Step 2: After authorizing, you'll see a blank page. The URL will look like:
-http://127.0.0.1:{callback_server_port}/callback?code=...
- Step 3: Change 127.0.0.1 to {host} and press Enter:
-
-
http://{host}:{callback_server_port}/callback?code=...
- Authentication will then complete!
+ # 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''' + + + + + +Click the link below to authenticate:
After authentication, return to the app.
' +{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}/callbackStep 1: Click the link below to authenticate with Spotify
+ +Step 2: After authorizing, you'll see a blank page. The URL will look like:
+http://127.0.0.1:{callback_server_port}/callback?code=...
+ Step 3: Change 127.0.0.1 to {host} and press Enter:
+
+
http://{host}:{callback_server_port}/callback?code=...
+ Authentication will then complete!
+ + + + + ''' else: return "Could not initialize Spotify client. Check your credentials.
", 400 except Exception as e: @@ -5735,6 +5719,33 @@ def auth_tidal(): return f"{str(e)}
", 500 +def _spotify_auth_success_page(detail_text: str) -> str: + """Return the post-auth success page and notify the opener.""" + return f""" + + + +{detail_text}
+ + +""" + + @app.route('/callback') def spotify_callback(): """ @@ -5796,7 +5807,7 @@ def spotify_callback(): # 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 "Your personal Spotify account is now connected. You can close this window.
" + return _spotify_auth_success_page("Your personal Spotify account is now connected. You can close this window.") else: raise Exception("Failed to exchange authorization code for access token") @@ -5833,7 +5844,7 @@ 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 "You can close this window.
" + return _spotify_auth_success_page("You can close this window.") else: raise Exception("Token exchange succeeded but authentication validation failed") else: @@ -5849,13 +5860,16 @@ def spotify_disconnect(): """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 - active_source = get_spotify_disconnect_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': False, 'authenticated': False, @@ -5867,7 +5881,13 @@ def spotify_disconnect(): } _status_cache_timestamps['spotify'] = time.time() 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}) + 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 @@ -31970,7 +31990,7 @@ def start_oauth_callback_servers(): self.send_response(200) self.send_header('Content-type', 'text/html') self.end_headers() - self.wfile.write(b'You can close this window.
') + self.wfile.write(_spotify_auth_success_page("You can close this window.").encode("utf-8")) else: raise Exception("Token exchange succeeded but authentication validation failed") else: diff --git a/webui/index.html b/webui/index.html index 7cd8bb17..4652f7e6 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3702,7 +3702,7 @@