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:
parent
d16aaab0aa
commit
718c5cb3d7
2 changed files with 55 additions and 34 deletions
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue