Fix Spotify auth validation flow

- use a shared Spotify OAuth scope everywhere so callback tokens and runtime validation expect the same permissions
- store Spotify OAuth caches at absolute project paths for global and per-profile auth flows
- validate post-callback success with is_spotify_authenticated() instead of the fallback-friendly is_authenticated()
- clear any active Spotify rate-limit cooldown before immediate post-auth validation
- improve auth probe logging so post-callback failures expose the real exception
This commit is contained in:
Antti Kettunen 2026-04-06 12:39:40 +03:00
parent d16aaab0aa
commit 718c5cb3d7
2 changed files with 55 additions and 34 deletions

View file

@ -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
return found

View file

@ -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 "<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>"
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'<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>')
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: