Add Spotify integration with improved token management and user interface

- Implemented Spotify OAuth handling in spotify_client_manager.py to ensure valid access tokens for users.
- Created helper functions in spotify_helper.py for refreshing tokens and retrieving user information.
- Developed manage_spotify.html template for connecting and managing Spotify accounts, displaying connection status and token information.
- Added logging for token management processes to enhance debugging and monitoring.
- Introduced a debug client for Spotify interactions to facilitate easier testing and development.
This commit is contained in:
Christian Krakau-Louis
2025-05-27 21:40:37 +02:00
parent d2772b88fe
commit 2f55f898ed
44 changed files with 2317 additions and 1884 deletions
+70 -3
View File
@@ -4,13 +4,10 @@ Authentication helper functions for OAuth providers
import os
from flask import current_app, url_for, session, flash, redirect, request
from authlib.integrations.flask_client import OAuth
from flask_login import login_user, current_user
from functools import wraps
from datetime import datetime, timedelta
import requests
from musicround.models import db, User
# Initialize OAuth object
oauth = OAuth()
@@ -68,6 +65,26 @@ def init_oauth(app):
app.logger.info("Dropbox OAuth client registered")
else:
app.logger.warning("Dropbox OAuth client not registered - missing app key or secret")
# Register Spotify OAuth client
if app.config.get('SPOTIFY_CLIENT_ID') and app.config.get('SPOTIFY_CLIENT_SECRET'):
oauth.register(
name='spotify',
client_id=app.config.get('SPOTIFY_CLIENT_ID'),
client_secret=app.config.get('SPOTIFY_CLIENT_SECRET'),
api_base_url='https://api.spotify.com/v1/',
authorize_url='https://accounts.spotify.com/authorize',
authorize_params={'show_dialog': 'true'}, # Force re-approval
access_token_url='https://accounts.spotify.com/api/token',
access_token_params=None,
refresh_token_url='https://accounts.spotify.com/api/token',
client_kwargs={
'scope': app.config.get('SPOTIFY_SCOPE')
},
userinfo_endpoint='https://api.spotify.com/v1/me' # Added for fetching user info
)
app.logger.info("Spotify OAuth client registered")
else:
app.logger.warning("Spotify OAuth client not registered - missing client ID or secret")
return oauth
@@ -171,10 +188,48 @@ def get_dropbox_user_info(token):
current_app.logger.error(f"Error getting Dropbox user info: {str(e)}")
return None
def get_spotify_user_info(token):
"""
Get Spotify user info from the token
"""
try:
# Authlib should handle token refresh automatically if configured correctly
# and if the token object is managed by Authlib's token session or similar mechanism.
# Use the registered Authlib client to fetch user info
# The 'userinfo_endpoint' configured during registration will be used.
# We pass the token explicitly to ensure it's used for this request.
# Authlib's `oauth.spotify.get()` will prepend the base URL if 'userinfo_endpoint' is relative,
# but since we provided an absolute one, it should use that.
# The error "Invalid URL 'me'" suggests that 'me' alone was passed somewhere.
# Let's ensure we are calling the fully qualified endpoint via the client.
resp = oauth.spotify.get('https://api.spotify.com/v1/me', token=token)
resp.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
profile = resp.json()
current_app.logger.debug(f"Spotify user info response: {profile}")
user_info = {
'id': profile.get('id'),
'email': profile.get('email'), # Note: Spotify email might be private
'name': profile.get('display_name'),
'picture': profile.get('images')[0]['url'] if profile.get('images') else None,
# Spotify doesn't provide given_name and family_name directly
'given_name': profile.get('display_name', '').split(' ')[0] if profile.get('display_name') else '',
'family_name': ' '.join(profile.get('display_name', '').split(' ')[1:]) if profile.get('display_name') and ' ' in profile.get('display_name') else ''
}
return user_info
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error getting Spotify user info: {http_err} - Response: {http_err.response.text}")
return None
except Exception as e:
current_app.logger.error(f"Error getting Spotify user info: {str(e)}")
return None
def find_or_create_user(user_info, auth_provider):
"""
Find existing user or create a new one based on OAuth user info
"""
from musicround.models import db, User
if not user_info:
return None
@@ -185,6 +240,8 @@ def find_or_create_user(user_info, auth_provider):
user = User.query.filter_by(authentik_id=user_info['id']).first()
elif auth_provider == 'dropbox':
user = User.query.filter_by(dropbox_id=user_info['id']).first()
elif auth_provider == 'spotify':
user = User.query.filter_by(spotify_id=user_info['id']).first()
else:
return None
@@ -200,6 +257,8 @@ def find_or_create_user(user_info, auth_provider):
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
elif auth_provider == 'spotify':
user.spotify_id = user_info['id']
db.session.commit()
current_app.logger.info(f"Updated existing user {user.username} with {auth_provider} ID")
@@ -243,6 +302,8 @@ def find_or_create_user(user_info, auth_provider):
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
elif auth_provider == 'spotify':
user.spotify_id = user_info['id']
db.session.add(user)
try:
@@ -259,6 +320,7 @@ def update_oauth_tokens(user, tokens, auth_provider):
"""
Update user's OAuth tokens
"""
from musicround.models import db
if auth_provider == 'google':
user.google_token = tokens.get('access_token')
user.google_refresh_token = tokens.get('refresh_token')
@@ -270,6 +332,11 @@ def update_oauth_tokens(user, tokens, auth_provider):
user.dropbox_refresh_token = tokens.get('refresh_token')
if tokens.get('expires_in'):
user.dropbox_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in')))
elif auth_provider == 'spotify':
user.spotify_token = tokens.get('access_token')
user.spotify_refresh_token = tokens.get('refresh_token')
if tokens.get('expires_in'):
user.spotify_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in')))
user.last_login = datetime.now()
try:
db.session.commit()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
"""
Updated core.py with improved Spotify OAuth handling
"""
import logging
import datetime
from flask import current_app, redirect, url_for, flash, session
from flask_login import current_user
from musicround.helpers.spotify_debug import DebugSpotifyClient
from musicround.models import db
logger = logging.getLogger("spotify.token")
def ensure_valid_spotify_token(session, current_user):
"""
Ensure a valid Spotify token is available and return a Spotify client
Args:
session: The Flask session object
current_user: The current user object
Returns:
DebugSpotifyClient or None if no valid token
"""
# Get token from session
token = session.get('access_token')
# If no token in session but user is logged in with a token
if not token and hasattr(current_user, 'is_authenticated') and current_user.is_authenticated:
if hasattr(current_user, 'spotify_token') and current_user.spotify_token:
token = current_user.spotify_token
session['access_token'] = token
if not token:
logger.warning("No Spotify token available")
return None
# Create Spotify client
sp = DebugSpotifyClient(auth=token)
# Verify token is valid
try:
# Try a simple API call
sp.current_user()
return sp
except Exception as e:
logger.warning(f"Spotify token validation failed: {str(e)}")
# Try to refresh the token
if hasattr(current_user, 'is_authenticated') and current_user.is_authenticated and hasattr(current_user, 'spotify_refresh_token') and current_user.spotify_refresh_token:
try:
from spotipy.oauth2 import SpotifyOAuth
from musicround.config import Config
# Create OAuth object to refresh token
sp_oauth = SpotifyOAuth(
client_id=Config.SPOTIFY_CLIENT_ID,
client_secret=Config.SPOTIFY_CLIENT_SECRET,
redirect_uri=Config.SPOTIFY_REDIRECT_URI,
scope=Config.SPOTIFY_SCOPE
)
# Get new token
token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token)
# Update session and user
session['access_token'] = token_info['access_token']
current_user.spotify_token = token_info['access_token']
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
# Update token expiry
current_user.spotify_token_expiry = datetime.datetime.now() + datetime.timedelta(seconds=token_info['expires_in'])
# Save changes
db.session.commit()
# Create new Spotify client with updated token
sp = DebugSpotifyClient(auth=token_info['access_token'])
return sp
except Exception as refresh_error:
logger.error(f"Error refreshing token: {str(refresh_error)}")
return None
View File
+193
View File
@@ -0,0 +1,193 @@
"""
Spotify token management helper functions
Provides centralized token refresh functionality similar to Dropbox helper
"""
import requests
import time
from datetime import datetime, timedelta
from flask import current_app
from flask_login import current_user
from musicround.models import db, SystemSetting
def refresh_spotify_token(refresh_token):
"""Refresh an expired Spotify access token"""
client_id = current_app.config.get('SPOTIFY_CLIENT_ID')
client_secret = current_app.config.get('SPOTIFY_CLIENT_SECRET')
if not client_id or not client_secret:
current_app.logger.error("Spotify client credentials not configured")
return None
data = {
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': client_id,
'client_secret': client_secret
}
response = requests.post('https://accounts.spotify.com/api/token', data=data)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error refreshing Spotify token: {response.text}")
return None
def get_current_user_spotify_token():
"""Get a valid Spotify access token for the current user, refreshing if needed"""
if not current_user or not current_user.is_authenticated:
current_app.logger.error("No authenticated user")
return None
# Check if token exists and is valid
if (current_user.spotify_token and
current_user.spotify_token_expiry and
current_user.spotify_token_expiry > datetime.now() + timedelta(minutes=5)):
# Token is valid and not about to expire
current_app.logger.debug(f"Using valid Spotify token for user {current_user.id}")
return current_user.spotify_token
# Token is missing or about to expire - try to refresh
if current_user.spotify_refresh_token:
current_app.logger.info(f"Refreshing Spotify token for user {current_user.id}")
# Try to refresh the token
token_info = refresh_spotify_token(current_user.spotify_refresh_token)
if token_info and 'access_token' in token_info:
# Update token in database
current_user.spotify_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600) # Default to 1 hour if not specified
current_user.spotify_token_expiry = datetime.now() + timedelta(seconds=expires_in)
# Update refresh token if a new one was provided
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
db.session.commit()
current_app.logger.info(f"Successfully refreshed Spotify token for user {current_user.id}")
return current_user.spotify_token
# If we get here, we couldn't refresh the user's token
current_app.logger.error(f"Failed to get valid Spotify token for user {current_user.id}")
return None
def get_system_spotify_token():
"""Get a valid Spotify access token from system refresh token, refreshing if needed"""
system_refresh_token = SystemSetting.get('fallback_spotify_refresh_token', '')
if not system_refresh_token:
current_app.logger.debug("No system Spotify refresh token available")
return None
# Check if we have a cached system token that's still valid
system_token = SystemSetting.get('system_spotify_token', '')
system_token_expiry_str = SystemSetting.get('system_spotify_token_expiry', '')
if system_token and system_token_expiry_str:
try:
system_token_expiry = datetime.fromisoformat(system_token_expiry_str)
if system_token_expiry > datetime.now() + timedelta(minutes=5):
current_app.logger.debug("Using valid cached system Spotify token")
return system_token
except ValueError:
current_app.logger.warning("Invalid system token expiry format")
# Token is missing or about to expire - try to refresh
current_app.logger.info("Refreshing system Spotify token")
token_info = refresh_spotify_token(system_refresh_token)
if token_info and 'access_token' in token_info:
# Cache the new token
new_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600) # Default to 1 hour if not specified
expiry = datetime.now() + timedelta(seconds=expires_in)
SystemSetting.set('system_spotify_token', new_token)
SystemSetting.set('system_spotify_token_expiry', expiry.isoformat())
# Update refresh token if a new one was provided
if 'refresh_token' in token_info:
SystemSetting.set('fallback_spotify_refresh_token', token_info['refresh_token'])
current_app.logger.info("Successfully refreshed system Spotify token")
return new_token
# If we get here, we couldn't refresh the system token
current_app.logger.error("Failed to get valid system Spotify token")
return None
def get_spotify_token():
"""
Get the best available Spotify token with automatic refresh
Priority: User token -> System token -> None
"""
# Try user token first
user_token = get_current_user_spotify_token()
if user_token:
return user_token, 'user'
# Fall back to system token
system_token = get_system_spotify_token()
if system_token:
return system_token, 'system'
# No valid tokens available
current_app.logger.warning("No valid Spotify tokens available")
return None, 'none'
def refresh_spotify_token_if_needed(token, refresh_token, token_expiry):
"""
Check if a token needs refresh and refresh it if necessary
Returns: (new_token, new_refresh_token, new_expiry) or (None, None, None) if failed
"""
# Check if token is still valid
if token and token_expiry and token_expiry > datetime.now() + timedelta(minutes=5):
return token, refresh_token, token_expiry
# Token needs refresh
if not refresh_token:
current_app.logger.error("Token expired but no refresh token available")
return None, None, None
token_info = refresh_spotify_token(refresh_token)
if token_info and 'access_token' in token_info:
new_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600)
new_expiry = datetime.now() + timedelta(seconds=expires_in)
new_refresh_token = token_info.get('refresh_token', refresh_token)
return new_token, new_refresh_token, new_expiry
return None, None, None
def get_spotify_user_info(access_token):
"""Get Spotify user info using an access token"""
if not access_token:
return None
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
try:
response = requests.get('https://api.spotify.com/v1/me', headers=headers)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error getting Spotify user info: {response.status_code} - {response.text}")
return None
except Exception as e:
current_app.logger.error(f"Exception getting Spotify user info: {str(e)}")
return None
View File