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 from typing import Dict, List, Optional, Any
import time import time
import threading import threading
from pathlib import Path
from functools import wraps from functools import wraps
from dataclasses import dataclass from dataclasses import dataclass
from utils.logging_config import get_logger 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 _ESCALATION_MAX = 14400 # 4 hours max ban
_BASE_UNKNOWN_BAN = 1800 # 30 min default when Retry-After header is missing _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) _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): class SpotifyRateLimitError(Exception):
"""Raised when Spotify API calls are blocked due to active global rate limit ban.""" """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_id=config['client_id'],
client_secret=config['client_secret'], client_secret=config['client_secret'],
redirect_uri=config.get('redirect_uri', "http://127.0.0.1:8888/callback"), 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", scope=SPOTIFY_USER_SCOPE,
cache_path='config/.spotify_cache' cache_path=get_spotify_cache_path()
) )
self.sp = spotipy.Spotify(auth_manager=auth_manager, retries=0, requests_timeout=15) 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") logger.warning(f"Auth probe rate limited — activating {ban_duration}s global ban")
result = True result = True
else: else:
logger.debug(f"Spotify authentication check failed: {e}") logger.warning(f"Spotify authentication check failed: {type(e).__name__}: {e}")
result = False result = False
with self._auth_cache_lock: with self._auth_cache_lock:
@ -644,16 +656,16 @@ class SpotifyClient:
def disconnect(self): def disconnect(self):
"""Disconnect Spotify: clear client, delete cache, invalidate auth cache, clear rate limit""" """Disconnect Spotify: clear client, delete cache, invalidate auth cache, clear rate limit"""
import os
self.sp = None self.sp = None
self.user_id = None self.user_id = None
self._invalidate_auth_cache() self._invalidate_auth_cache()
_clear_rate_limit() _clear_rate_limit()
cache_path = 'config/.spotify_cache' cache_path = get_spotify_cache_path()
try: try:
if os.path.exists(cache_path): cache_file = Path(cache_path)
os.remove(cache_path) if cache_file.exists():
cache_file.unlink()
logger.info("Deleted Spotify cache file") logger.info("Deleted Spotify cache file")
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete Spotify cache: {e}") logger.warning(f"Failed to delete Spotify cache: {e}")

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_handler.setFormatter(_logging.Formatter("%(asctime)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
pp_logger.addHandler(_pp_handler) pp_logger.addHandler(_pp_handler)
pp_logger.propagate = False 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.plex_client import PlexClient
from core.jellyfin_client import JellyfinClient from core.jellyfin_client import JellyfinClient
from core.navidrome_client import NavidromeClient 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_id=creds['client_id'],
client_secret=creds['client_secret'], client_secret=creds['client_secret'],
redirect_uri=creds.get('redirect_uri', 'http://127.0.0.1:8888/callback'), 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", scope=SPOTIFY_USER_SCOPE,
cache_path=f'config/.spotify_cache_profile_{profile_id}' cache_path=get_spotify_cache_path(profile_id)
) )
# Create a bare SpotifyClient and immediately set the profile-specific # Create a bare SpotifyClient and immediately set the profile-specific
@ -6397,14 +6404,15 @@ def auth_spotify():
db = get_database() db = get_database()
creds = db.get_profile_spotify(profile_id_int) creds = db.get_profile_spotify(profile_id_int)
if creds and creds.get('client_id'): if creds and creds.get('client_id'):
from core.spotify_client import get_spotify_cache_path
from spotipy.oauth2 import SpotifyOAuth 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') 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( auth_manager = SpotifyOAuth(
client_id=creds['client_id'], client_id=creds['client_id'],
client_secret=creds['client_secret'], client_secret=creds['client_secret'],
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", scope=SPOTIFY_USER_SCOPE,
cache_path=f'config/.spotify_cache_profile_{profile_id_int}', cache_path=get_spotify_cache_path(profile_id_int),
state=f'profile_{profile_id_int}' state=f'profile_{profile_id_int}'
) )
auth_url = auth_manager.get_authorize_url() auth_url = auth_manager.get_authorize_url()
@ -6710,7 +6718,7 @@ def spotify_callback():
pass pass
try: try:
from core.spotify_client import SpotifyClient from core.spotify_client import SpotifyClient, get_spotify_cache_path
from spotipy.oauth2 import SpotifyOAuth from spotipy.oauth2 import SpotifyOAuth
from config.settings import config_manager from config.settings import config_manager
@ -6724,8 +6732,8 @@ def spotify_callback():
client_id=creds['client_id'], client_id=creds['client_id'],
client_secret=creds['client_secret'], client_secret=creds['client_secret'],
redirect_uri=redirect_uri, redirect_uri=redirect_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", scope=SPOTIFY_USER_SCOPE,
cache_path=f'config/.spotify_cache_profile_{profile_id_from_state}', cache_path=get_spotify_cache_path(profile_id_from_state),
state=f'profile_{profile_id_from_state}' state=f'profile_{profile_id_from_state}'
) )
token_info = auth_manager.get_access_token(auth_code) token_info = auth_manager.get_access_token(auth_code)
@ -6747,8 +6755,8 @@ def spotify_callback():
client_id=config['client_id'], client_id=config['client_id'],
client_secret=config['client_secret'], client_secret=config['client_secret'],
redirect_uri=configured_uri, redirect_uri=configured_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", scope=SPOTIFY_USER_SCOPE,
cache_path='config/.spotify_cache' cache_path=get_spotify_cache_path()
) )
token_info = auth_manager.get_access_token(auth_code) 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 # CRITICAL: update the GLOBAL spotify_client, not a local variable
global spotify_client global spotify_client
spotify_client = SpotifyClient() spotify_client = SpotifyClient()
if spotify_client.is_authenticated(): # Clear any active rate limit ban and post-ban cooldown before validation,
# Clear any active rate limit ban and post-ban cooldown # otherwise a fresh re-auth can still look unauthenticated during cooldown.
# so Spotify is immediately usable after re-auth
from core.spotify_client import _clear_rate_limit from core.spotify_client import _clear_rate_limit
_clear_rate_limit() _clear_rate_limit()
spotify_client._invalidate_auth_cache() spotify_client._invalidate_auth_cache()
if spotify_client.is_spotify_authenticated():
# Invalidate status cache so next poll picks up the new connection # Invalidate status cache so next poll picks up the new connection
_status_cache_timestamps['spotify'] = 0 _status_cache_timestamps['spotify'] = 0
# Refresh enrichment worker's client so it picks up new auth # 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") add_activity_item("", "Spotify Auth Complete", "Successfully authenticated with Spotify", "Now")
return "<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>" return "<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>"
else: else:
raise Exception("Token exchange succeeded but authentication validation failed") raise Exception("Token exchange succeeded but Spotify is still not authenticated")
else: else:
raise Exception("Failed to exchange authorization code for access token") raise Exception("Failed to exchange authorization code for access token")
except Exception as e: except Exception as e:
@ -47678,7 +47686,7 @@ def start_oauth_callback_servers():
# Manually trigger the token exchange using spotipy's auth manager # Manually trigger the token exchange using spotipy's auth manager
try: try:
from core.spotify_client import SpotifyClient from core.spotify_client import SpotifyClient, get_spotify_cache_path
from spotipy.oauth2 import SpotifyOAuth from spotipy.oauth2 import SpotifyOAuth
from config.settings import config_manager from config.settings import config_manager
@ -47692,8 +47700,8 @@ def start_oauth_callback_servers():
client_id=config['client_id'], client_id=config['client_id'],
client_secret=config['client_secret'], client_secret=config['client_secret'],
redirect_uri=configured_uri, redirect_uri=configured_uri,
scope="user-library-read user-read-private playlist-read-private playlist-read-collaborative user-read-email", scope=SPOTIFY_USER_SCOPE,
cache_path='config/.spotify_cache' cache_path=get_spotify_cache_path()
) )
# Extract the authorization code and exchange it for tokens # Extract the authorization code and exchange it for tokens
@ -47704,11 +47712,12 @@ def start_oauth_callback_servers():
global spotify_client global spotify_client
spotify_client = SpotifyClient() spotify_client = SpotifyClient()
if spotify_client.is_authenticated(): # Clear rate limit ban + post-ban cooldown before validation.
# Clear rate limit ban + post-ban cooldown so Spotify is usable immediately
from core.spotify_client import _clear_rate_limit from core.spotify_client import _clear_rate_limit
_clear_rate_limit() _clear_rate_limit()
spotify_client._invalidate_auth_cache() spotify_client._invalidate_auth_cache()
if spotify_client.is_spotify_authenticated():
# Invalidate status cache so next poll picks up the new connection # Invalidate status cache so next poll picks up the new connection
_status_cache_timestamps['spotify'] = 0 _status_cache_timestamps['spotify'] = 0
# Refresh enrichment worker's client so it picks up new auth # Refresh enrichment worker's client so it picks up new auth
@ -47721,7 +47730,7 @@ def start_oauth_callback_servers():
self.end_headers() self.end_headers()
self.wfile.write(b'<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>') self.wfile.write(b'<h1>Spotify Authentication Successful!</h1><p>You can close this window.</p>')
else: else:
raise Exception("Token exchange succeeded but authentication validation failed") raise Exception("Token exchange succeeded but Spotify is still not authenticated")
else: else:
raise Exception("Failed to exchange authorization code for access token") raise Exception("Failed to exchange authorization code for access token")
except Exception as e: except Exception as e: