diff --git a/Dockerfile b/Dockerfile index 566de2c5..5ade62ce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -139,6 +139,7 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ ENV PYTHONPATH=/app ENV PYTHONUNBUFFERED=1 ENV DATABASE_PATH=/app/data/music_library.db +ENV SOULSYNC_CONFIG_PATH=/app/config/config.json ENV PUID=1000 ENV PGID=1000 ENV UMASK=022 diff --git a/core/metadata/registry.py b/core/metadata/registry.py index a6c6ea39..8bb8c1aa 100644 --- a/core/metadata/registry.py +++ b/core/metadata/registry.py @@ -352,10 +352,21 @@ def get_hydrabase_client(allow_fallback: bool = True, require_enabled: bool = Tr return None +def get_configured_primary_source() -> str: + """Return metadata.fallback_source as stored in config, without runtime downgrade. + + Unlike get_primary_source(), this never probes Spotify auth. Use at boot and + anywhere blocking network I/O must be avoided (gunicorn worker import, Docker + cold start when Spotify is unreachable). + """ + _default = METADATA_SOURCE_PRIORITY[0] + return _get_config_value("metadata.fallback_source", _default) or _default + + def get_primary_source(spotify_client_factory: Optional[MetadataClientFactory] = None) -> str: """Return configured primary metadata source.""" _default = METADATA_SOURCE_PRIORITY[0] - source = _get_config_value("metadata.fallback_source", _default) or _default + source = get_configured_primary_source() if source == "spotify": try: diff --git a/core/metadata_service.py b/core/metadata_service.py index 7c390746..fd412a99 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -49,6 +49,7 @@ from core.metadata.registry import ( get_discogs_client, get_hydrabase_client, get_itunes_client, + get_configured_primary_source, get_primary_client, get_primary_source, get_primary_source_label, @@ -117,6 +118,7 @@ __all__ = [ "get_metadata_service", "get_musicmap_similar_artists", "get_primary_client", + "get_configured_primary_source", "get_primary_source", "get_primary_source_label", "get_spotify_client_for_profile", diff --git a/core/spotify_client.py b/core/spotify_client.py index b5318037..d0d74c55 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -857,7 +857,8 @@ class SpotifyClient: # Use a dedicated probe client (retries=0) so a 429 here propagates # immediately and we can detect long Retry-After bans. try: - probe = spotipy.Spotify(auth_manager=self.sp.auth_manager, retries=0) + probe = spotipy.Spotify( + auth_manager=self.sp.auth_manager, retries=0, requests_timeout=15) probe.current_user() result = True except Exception as e: diff --git a/tests/metadata/test_configured_primary_source.py b/tests/metadata/test_configured_primary_source.py new file mode 100644 index 00000000..68ae014b --- /dev/null +++ b/tests/metadata/test_configured_primary_source.py @@ -0,0 +1,27 @@ +"""Tests for boot-safe configured primary source lookup.""" + +from unittest.mock import MagicMock, patch + +from core.metadata import registry + + +def test_get_configured_primary_source_reads_config_without_auth_probe(monkeypatch): + monkeypatch.setattr( + registry, + "_get_config_value", + lambda key, default=None: "spotify" if key == "metadata.fallback_source" else default, + ) + + with patch.object(registry, "get_spotify_client") as get_client: + assert registry.get_configured_primary_source() == "spotify" + get_client.assert_not_called() + + +def test_get_primary_source_still_downgrades_unauthenticated_spotify(monkeypatch): + monkeypatch.setattr(registry, "get_configured_primary_source", lambda: "spotify") + + spotify = MagicMock() + spotify.is_spotify_authenticated.return_value = False + monkeypatch.setattr(registry, "get_spotify_client", lambda **_: spotify) + + assert registry.get_primary_source() == registry.METADATA_SOURCE_PRIORITY[0] diff --git a/web_server.py b/web_server.py index 15b5de83..d06ebb4f 100644 --- a/web_server.py +++ b/web_server.py @@ -36242,11 +36242,13 @@ except Exception as e: # they explicitly want background Spotify enrichment. spotify_enrichment_worker = None try: - from core.metadata_service import get_primary_source as _get_primary_source + from core.metadata_service import get_configured_primary_source from database.music_database import MusicDatabase spotify_enrichment_db = MusicDatabase() spotify_enrichment_worker = SpotifyWorker(database=spotify_enrichment_db) - _primary = _get_primary_source() + # Use configured source only — get_primary_source() probes Spotify auth and can + # block gunicorn worker boot indefinitely when the API is unreachable. + _primary = get_configured_primary_source() _user_paused = config_manager.get('spotify_enrichment_paused', False) if _user_paused or _primary != 'spotify': spotify_enrichment_worker.paused = True # Set BEFORE start() to prevent race condition