diff --git a/core/spotify_client.py b/core/spotify_client.py index 66514c2d..de246829 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -53,6 +53,36 @@ def normalize_spotify_oauth_config(config: Optional[Dict[str, Any]]) -> Dict[str return normalized +def build_spotify_oauth_auth_manager( + config_manager=None, + *, + config: Optional[Dict[str, Any]] = None, +) -> Optional[SpotifyOAuth]: + """Build SpotifyOAuth with the database-backed token cache. + + OAuth callbacks and SpotifyClient must share the same cache store. + Callbacks that wrote to ``config/.spotify_cache`` while the client read + ``spotify.token_info`` from the DB left validation probing with a stale or + missing token; spotipy then tried interactive OAuth on port 8888 → + EADDRINUSE inside Docker. + """ + from config.settings import config_manager as default_cm + from core.spotify_token_cache import DatabaseTokenCache + + cm = config_manager or default_cm + cfg = normalize_spotify_oauth_config(config or cm.get_spotify_config()) + if not cfg.get("client_id") or not cfg.get("client_secret"): + return None + + return SpotifyOAuth( + client_id=cfg["client_id"], + client_secret=cfg["client_secret"], + redirect_uri=cfg.get("redirect_uri", "http://127.0.0.1:8888/callback"), + scope=SPOTIFY_OAUTH_SCOPE, + cache_handler=DatabaseTokenCache(cm), + ) + + def _upgrade_spotify_image_url(url: str) -> str: """Upgrade a Spotify CDN image URL to the highest available resolution. @@ -744,22 +774,11 @@ class SpotifyClient: return try: - # Tokens live in the database-backed config store, not a loose - # file: config/.spotify_cache sat in /app/config, which is only - # persistent when the user's compose maps it — an anonymous - # volume is recreated empty on every container pull, so tokens - # died nightly while every other setting survived ("it keeps - # unauthenticating" daily, wolf39us). The handler imports the - # legacy file once if the store is empty. - from core.spotify_token_cache import DatabaseTokenCache - auth_manager = SpotifyOAuth( - client_id=config['client_id'], - client_secret=config['client_secret'], - redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"), - scope=SPOTIFY_OAUTH_SCOPE, - cache_handler=DatabaseTokenCache(config_manager) - ) - + auth_manager = build_spotify_oauth_auth_manager(config_manager, config=config) + if auth_manager is None: + logger.warning("Spotify credentials not configured") + return + self.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15) # retries=0: prevent spotipy from sleeping for Retry-After duration on 429s # (can be hours). Our rate_limited decorator + global ban handle retries instead. diff --git a/tests/test_spotify_client.py b/tests/test_spotify_client.py index 4dc18dfb..c1d1609e 100644 --- a/tests/test_spotify_client.py +++ b/tests/test_spotify_client.py @@ -3,7 +3,10 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from core.spotify_client import normalize_spotify_oauth_config +from unittest.mock import MagicMock, patch + +from core.spotify_client import build_spotify_oauth_auth_manager, normalize_spotify_oauth_config +from core.spotify_token_cache import DatabaseTokenCache def test_normalization(): # Whitespace + quotes are stripped (paste garbage); the redirect_uri's @@ -89,6 +92,28 @@ def test_no_input(): assert normalize_spotify_oauth_config(None) == {} assert normalize_spotify_oauth_config(config) == expected + +def test_build_spotify_oauth_auth_manager_uses_database_cache(): + cm = MagicMock() + cm.get_spotify_config.return_value = { + "client_id": "client_id", + "client_secret": "client_secret", + "redirect_uri": "http://127.0.0.1:8888/callback", + } + with patch("core.spotify_client.SpotifyOAuth") as mock_oauth: + auth_manager = build_spotify_oauth_auth_manager(cm) + assert auth_manager is mock_oauth.return_value + kwargs = mock_oauth.call_args.kwargs + assert isinstance(kwargs["cache_handler"], DatabaseTokenCache) + assert kwargs["scope"] + assert "cache_path" not in kwargs + + +def test_build_spotify_oauth_auth_manager_returns_none_without_creds(): + cm = MagicMock() + cm.get_spotify_config.return_value = {"client_id": "", "client_secret": ""} + assert build_spotify_oauth_auth_manager(cm) is None + # ── create_or_update_playlist: export a mirrored playlist back to Spotify (#945) ── from core.spotify_client import SpotifyClient as _SpotifyClient diff --git a/web_server.py b/web_server.py index 83e88a87..60d0618e 100644 --- a/web_server.py +++ b/web_server.py @@ -5195,8 +5195,11 @@ def spotify_callback(): pass try: - from core.spotify_client import SpotifyClient, normalize_spotify_oauth_config - from spotipy.oauth2 import SpotifyOAuth + from core.spotify_client import ( + SpotifyClient, + build_spotify_oauth_auth_manager, + normalize_spotify_oauth_config, + ) from config.settings import config_manager # Per-profile callback: the profile's own account via the shared app. @@ -5229,13 +5232,9 @@ def spotify_callback(): configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback") logger.info(f"Using redirect_uri for token exchange: {configured_uri}") - auth_manager = SpotifyOAuth( - client_id=config['client_id'], - client_secret=config['client_secret'], - redirect_uri=configured_uri, - scope=SPOTIFY_OAUTH_SCOPE, - cache_path='config/.spotify_cache' - ) + auth_manager = build_spotify_oauth_auth_manager(config_manager) + if not auth_manager: + raise Exception("Spotify credentials not configured") token_info = auth_manager.get_access_token(auth_code) @@ -5244,6 +5243,7 @@ def spotify_callback(): global spotify_client clear_cached_metadata_client("spotify") spotify_client = get_spotify_client() + spotify_client._invalidate_auth_cache() if spotify_client.is_spotify_authenticated(): # Clear any active rate limit ban and post-ban cooldown # so Spotify is immediately usable after re-auth @@ -36102,25 +36102,21 @@ def start_oauth_callback_servers(): # Manually trigger the token exchange using spotipy's auth manager try: - from spotipy.oauth2 import SpotifyOAuth from config.settings import config_manager - from core.spotify_client import normalize_spotify_oauth_config + from core.spotify_client import ( + build_spotify_oauth_auth_manager, + normalize_spotify_oauth_config, + ) # Get Spotify config config = normalize_spotify_oauth_config(config_manager.get_spotify_config()) configured_uri = config.get('redirect_uri', "http://127.0.0.1:8888/callback") _oauth_logger.info(f"Using redirect_uri for token exchange: {configured_uri}") - # Create auth manager and exchange code for token - auth_manager = SpotifyOAuth( - client_id=config['client_id'], - client_secret=config['client_secret'], - redirect_uri=configured_uri, - scope=SPOTIFY_OAUTH_SCOPE, - cache_path='config/.spotify_cache' - ) + auth_manager = build_spotify_oauth_auth_manager(config_manager) + if not auth_manager: + raise Exception("Spotify credentials not configured") - # Extract the authorization code and exchange it for tokens token_info = auth_manager.get_access_token(auth_code) if token_info: @@ -36128,6 +36124,7 @@ def start_oauth_callback_servers(): global spotify_client clear_cached_metadata_client("spotify") spotify_client = get_spotify_client() + spotify_client._invalidate_auth_cache() if spotify_client.is_spotify_authenticated(): # Clear rate limit ban + post-ban cooldown so Spotify is usable immediately