diff --git a/core/metadata/registry.py b/core/metadata/registry.py index 595c8549..d1162b35 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -18,6 +18,13 @@ logger = get_logger("metadata.registry") MetadataClientFactory = Callable[[], Any] METADATA_SOURCE_PRIORITY = ("deezer", "itunes", "spotify", "discogs", "hydrabase") +METADATA_SOURCE_LABELS = { + "spotify": "Spotify", + "itunes": "iTunes", + "deezer": "Deezer", + "discogs": "Discogs", + "hydrabase": "Hydrabase", +} _UNSET = object() _client_cache_lock = threading.RLock() @@ -293,6 +300,17 @@ def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = return source +def get_spotify_disconnect_source() -> str: + """Return the active metadata source after Spotify is disconnected.""" + source = get_primary_source() + return "deezer" if source == "spotify" else source + + +def get_metadata_source_label(source: str) -> str: + """Return a human-readable label for a metadata source.""" + return METADATA_SOURCE_LABELS.get(source, source.replace("_", " ").title()) + + def get_source_priority(preferred_source: str): """Return source priority with preferred source first.""" ordered = [] diff --git a/tests/conftest.py b/tests/conftest.py index a9976c6b..390252ea 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, 'response_time': 12.5, 'source': 'spotify'}, + 'spotify': {'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'}, } diff --git a/tests/metadata/test_metadata_registry.py b/tests/metadata/test_metadata_registry.py new file mode 100644 index 00000000..ea3a33d0 --- /dev/null +++ b/tests/metadata/test_metadata_registry.py @@ -0,0 +1,30 @@ +import sys +from pathlib import Path + +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_keeps_non_spotify_primary(monkeypatch): + monkeypatch.setattr(registry, "get_primary_source", lambda: "discogs") + + assert registry.get_spotify_disconnect_source() == "discogs" + + +def test_metadata_source_label_maps_known_sources(): + assert registry.get_metadata_source_label("spotify") == "Spotify" + assert registry.get_metadata_source_label("itunes") == "iTunes" + assert registry.get_metadata_source_label("deezer") == "Deezer" + assert registry.get_metadata_source_label("discogs") == "Discogs" + assert registry.get_metadata_source_label("hydrabase") == "Hydrabase" + + +def test_metadata_source_label_falls_back_to_title_case(): + assert registry.get_metadata_source_label("apple_music") == "Apple Music" diff --git a/tests/test_websocket_infrastructure.py b/tests/test_websocket_infrastructure.py index 8d1f4658..392f80aa 100644 --- a/tests/test_websocket_infrastructure.py +++ b/tests/test_websocket_infrastructure.py @@ -56,6 +56,7 @@ class TestServiceStatus: assert 'media_server' in data assert 'soulseek' in data assert 'active_media_server' in data + assert 'authenticated' in data['spotify'] def test_status_matches_http(self, test_app, shared_state): """Socket event data matches HTTP endpoint response exactly.""" diff --git a/web_server.py b/web_server.py index ace196fc..d42781f8 100644 --- a/web_server.py +++ b/web_server.py @@ -98,7 +98,9 @@ from core.metadata.cache import get_metadata_cache from core.metadata import registry as metadata_registry from core.metadata.registry import ( clear_cached_metadata_client, + get_metadata_source_label, get_spotify_client, + get_spotify_disconnect_source, register_runtime_clients, ) from core.imports.context import ( @@ -800,11 +802,11 @@ _idle_since = {} _IDLE_GRACE_SECONDS = 5 _status_cache = { - 'spotify': {'connected': False, 'response_time': 0, 'source': 'itunes'}, + 'spotify': {'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 = { +_status_cache_timestamps: dict[str, float] = { 'spotify': 0, 'media_server': 0, 'soulseek': 0, @@ -3424,6 +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) # Read configured source once — no auth validation here, we do that explicitly below configured_source = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' @@ -3445,6 +3448,7 @@ def get_status(): _status_cache['spotify'] = { 'connected': True, # Always true — iTunes fallback is always available + 'authenticated': spotify_session_active, 'response_time': round(spotify_response_time, 1), 'source': music_source, 'rate_limited': is_rate_limited, @@ -4773,7 +4777,9 @@ 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) _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 logger.info("Updated Spotify status cache after successful test") @@ -4939,7 +4945,9 @@ 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) _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 logger.info("Updated Spotify status cache after successful dashboard test") @@ -5838,7 +5846,7 @@ 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: # Pause enrichment worker before disconnecting to prevent it from hammering API @@ -5846,18 +5854,20 @@ def spotify_disconnect(): 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() + source_label = get_metadata_source_label(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}) except Exception as e: logger.error(f"Error disconnecting Spotify: {e}") return jsonify({'success': False, 'error': str(e)}), 500 diff --git a/webui/index.html b/webui/index.html index 87fe7938..7cd8bb17 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3693,7 +3693,7 @@
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.