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:
@@ -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()
|
||||
|
||||
+534
-417
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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user