diff --git a/core/spotify_client.py b/core/spotify_client.py index 8462417c..2c5a493e 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -3,6 +3,7 @@ from spotipy.oauth2 import SpotifyOAuth, SpotifyClientCredentials from typing import Dict, List, Optional, Any import time import threading +from pathlib import Path from functools import wraps from dataclasses import dataclass from utils.logging_config import get_logger @@ -56,6 +57,17 @@ _ESCALATION_WINDOW = 3600 # 1 hour — if re-limited within this, escalate _ESCALATION_MAX = 14400 # 4 hours max ban _BASE_UNKNOWN_BAN = 1800 # 30 min default when Retry-After header is missing _BASE_MAX_RETRIES_BAN = 14400 # 4 hours default when spotipy exhausted all retries (severe rate limit) +SPOTIFY_USER_SCOPE = ( + "user-library-read user-read-private playlist-read-private " + "playlist-read-collaborative user-read-email user-follow-read" +) + +def get_spotify_cache_path(profile_id: Optional[int] = None) -> str: + """Return an absolute cache path so OAuth tokens persist regardless of process CWD.""" + cache_dir = Path(config_manager.base_dir) / "config" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_name = ".spotify_cache" if not profile_id or profile_id == 1 else f".spotify_cache_profile_{profile_id}" + return str(cache_dir / cache_name) class SpotifyRateLimitError(Exception): """Raised when Spotify API calls are blocked due to active global rate limit ban.""" @@ -542,8 +554,8 @@ class SpotifyClient: client_id=config['client_id'], client_secret=config['client_secret'], redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"), - scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email user-follow-read", - cache_path='config/.spotify_cache' + scope=SPOTIFY_USER_SCOPE, + cache_path=get_spotify_cache_path() ) self.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15) @@ -633,7 +645,7 @@ class SpotifyClient: logger.warning(f"Auth probe rate limited — activating {ban_duration}s global ban") result = True else: - logger.debug(f"Spotify authentication check failed: {e}") + logger.warning(f"Spotify authentication check failed: {type(e).__name__}: {e}") result = False with self._auth_cache_lock: @@ -644,16 +656,16 @@ class SpotifyClient: def disconnect(self): """Disconnect Spotify: clear client, delete cache, invalidate auth cache, clear rate limit""" - import os self.sp = None self.user_id = None self._invalidate_auth_cache() _clear_rate_limit() - cache_path = 'config/.spotify_cache' + cache_path = get_spotify_cache_path() try: - if os.path.exists(cache_path): - os.remove(cache_path) + cache_file = Path(cache_path) + if cache_file.exists(): + cache_file.unlink() logger.info("Deleted Spotify cache file") except Exception as e: logger.warning(f"Failed to delete Spotify cache: {e}") @@ -1600,4 +1612,4 @@ class SpotifyClient: except Exception as e: logger.error(f"Error in batch artist fetch: {e}") - return found \ No newline at end of file + return found diff --git a/web_server.py b/web_server.py index f812f7a8..10a5d408 100644 --- a/web_server.py +++ b/web_server.py @@ -57,7 +57,14 @@ if not pp_logger.handlers: _pp_handler.setFormatter(_logging.Formatter("%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S")) pp_logger.addHandler(_pp_handler) pp_logger.propagate = False -from core.spotify_client import SpotifyClient, Playlist as SpotifyPlaylist, Track as SpotifyTrack, _is_globally_rate_limited as _spotify_rate_limited +from core.spotify_client import ( + SpotifyClient, + Playlist as SpotifyPlaylist, + Track as SpotifyTrack, + SPOTIFY_USER_SCOPE, + _is_globally_rate_limited as _spotify_rate_limited, + get_spotify_cache_path, +) from core.plex_client import PlexClient from core.jellyfin_client import JellyfinClient from core.navidrome_client import NavidromeClient @@ -245,8 +252,8 @@ def get_spotify_client_for_profile(profile_id=None): client_id=creds['client_id'], client_secret=creds['client_secret'], redirect_uri=creds.get('redirect_uri', 'http://127.0.0.1:8888/callback'), - scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", - cache_path=f'config/.spotify_cache_profile_{profile_id}' + scope=SPOTIFY_USER_SCOPE, + cache_path=get_spotify_cache_path(profile_id) ) # Create a bare SpotifyClient and immediately set the profile-specific @@ -6397,14 +6404,15 @@ def auth_spotify(): db = get_database() creds = db.get_profile_spotify(profile_id_int) if creds and creds.get('client_id'): + from core.spotify_client import get_spotify_cache_path from spotipy.oauth2 import SpotifyOAuth redirect_uri = creds.get('redirect_uri') or config_manager.get_spotify_config().get('redirect_uri', 'http://127.0.0.1:8888/callback') auth_manager = SpotifyOAuth( client_id=creds['client_id'], client_secret=creds['client_secret'], redirect_uri=redirect_uri, - scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", - cache_path=f'config/.spotify_cache_profile_{profile_id_int}', + scope=SPOTIFY_USER_SCOPE, + cache_path=get_spotify_cache_path(profile_id_int), state=f'profile_{profile_id_int}' ) auth_url = auth_manager.get_authorize_url() @@ -6710,7 +6718,7 @@ def spotify_callback(): pass try: - from core.spotify_client import SpotifyClient + from core.spotify_client import SpotifyClient, get_spotify_cache_path from spotipy.oauth2 import SpotifyOAuth from config.settings import config_manager @@ -6724,8 +6732,8 @@ def spotify_callback(): client_id=creds['client_id'], client_secret=creds['client_secret'], redirect_uri=redirect_uri, - scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", - cache_path=f'config/.spotify_cache_profile_{profile_id_from_state}', + scope=SPOTIFY_USER_SCOPE, + cache_path=get_spotify_cache_path(profile_id_from_state), state=f'profile_{profile_id_from_state}' ) token_info = auth_manager.get_access_token(auth_code) @@ -6747,8 +6755,8 @@ def spotify_callback(): client_id=config['client_id'], client_secret=config['client_secret'], redirect_uri=configured_uri, - scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", - cache_path='config/.spotify_cache' + scope=SPOTIFY_USER_SCOPE, + cache_path=get_spotify_cache_path() ) token_info = auth_manager.get_access_token(auth_code) @@ -6757,12 +6765,12 @@ def spotify_callback(): # CRITICAL: update the GLOBAL spotify_client, not a local variable global spotify_client spotify_client = SpotifyClient() - if spotify_client.is_authenticated(): - # Clear any active rate limit ban and post-ban cooldown - # so Spotify is immediately usable after re-auth - from core.spotify_client import _clear_rate_limit - _clear_rate_limit() - spotify_client._invalidate_auth_cache() + # Clear any active rate limit ban and post-ban cooldown before validation, + # otherwise a fresh re-auth can still look unauthenticated during cooldown. + from core.spotify_client import _clear_rate_limit + _clear_rate_limit() + spotify_client._invalidate_auth_cache() + if spotify_client.is_spotify_authenticated(): # Invalidate status cache so next poll picks up the new connection _status_cache_timestamps['spotify'] = 0 # Refresh enrichment worker's client so it picks up new auth @@ -6772,7 +6780,7 @@ def spotify_callback(): add_activity_item("✅", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now") return "

Spotify Authentication Successful!

You can close this window.

" else: - raise Exception("Token exchange succeeded but authentication validation failed") + raise Exception("Token exchange succeeded but Spotify is still not authenticated") else: raise Exception("Failed to exchange authorization code for access token") except Exception as e: @@ -47678,7 +47686,7 @@ def start_oauth_callback_servers(): # Manually trigger the token exchange using spotipy's auth manager try: - from core.spotify_client import SpotifyClient + from core.spotify_client import SpotifyClient, get_spotify_cache_path from spotipy.oauth2 import SpotifyOAuth from config.settings import config_manager @@ -47692,8 +47700,8 @@ def start_oauth_callback_servers(): client_id=config['client_id'], client_secret=config['client_secret'], redirect_uri=configured_uri, - scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", - cache_path='config/.spotify_cache' + scope=SPOTIFY_USER_SCOPE, + cache_path=get_spotify_cache_path() ) # Extract the authorization code and exchange it for tokens @@ -47704,11 +47712,12 @@ def start_oauth_callback_servers(): global spotify_client spotify_client = SpotifyClient() - if spotify_client.is_authenticated(): - # Clear rate limit ban + post-ban cooldown so Spotify is usable immediately - from core.spotify_client import _clear_rate_limit - _clear_rate_limit() - spotify_client._invalidate_auth_cache() + # Clear rate limit ban + post-ban cooldown before validation. + from core.spotify_client import _clear_rate_limit + _clear_rate_limit() + spotify_client._invalidate_auth_cache() + + if spotify_client.is_spotify_authenticated(): # Invalidate status cache so next poll picks up the new connection _status_cache_timestamps['spotify'] = 0 # Refresh enrichment worker's client so it picks up new auth @@ -47721,7 +47730,7 @@ def start_oauth_callback_servers(): self.end_headers() self.wfile.write(b'

Spotify Authentication Successful!

You can close this window.

') else: - raise Exception("Token exchange succeeded but authentication validation failed") + raise Exception("Token exchange succeeded but Spotify is still not authenticated") else: raise Exception("Failed to exchange authorization code for access token") except Exception as e: