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:
+125
-140
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import logging
|
||||
import importlib.util
|
||||
import json
|
||||
from flask import Flask, session, redirect, url_for, request
|
||||
from flask_login import LoginManager, current_user
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
@@ -8,10 +9,10 @@ from flask_wtf.csrf import CSRFProtect
|
||||
from dotenv import load_dotenv
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from importlib import import_module
|
||||
import spotipy
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
from musicround.config import Config
|
||||
from musicround.version import VERSION_INFO, get_version_str
|
||||
from datetime import datetime
|
||||
from musicround.helpers.auth_helpers import oauth # Import the oauth object
|
||||
|
||||
# Initialize SQLAlchemy
|
||||
db = SQLAlchemy()
|
||||
@@ -177,148 +178,132 @@ def create_app(config=None):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Initialize OAuth providers (Google, Authentik)
|
||||
# Define token handling functions within create_app
|
||||
def _app_fetch_token(name):
|
||||
app.logger.debug(f"_app_fetch_token: Called for service '{name}', user: {current_user.id if current_user.is_authenticated else 'Unauthenticated'}")
|
||||
if current_user.is_authenticated:
|
||||
if name == 'spotify':
|
||||
token_str = current_user.spotify_token
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Raw token string from DB: {token_str[:150] if token_str else 'None'}...")
|
||||
if token_str:
|
||||
try:
|
||||
token = json.loads(token_str)
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Token after json.loads: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'expires_in': {token.get('expires_in')}, 'scope': {token.get('scope')}, 'token_type': '{token.get('token_type')}'}}")
|
||||
|
||||
if 'refresh_token' not in token or not token.get('refresh_token'):
|
||||
if hasattr(current_user, 'spotify_refresh_token') and current_user.spotify_refresh_token:
|
||||
token['refresh_token'] = current_user.spotify_refresh_token
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Added refresh_token from current_user.spotify_refresh_token.")
|
||||
else:
|
||||
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): refresh_token missing in JSON and not found in current_user.spotify_refresh_token.")
|
||||
|
||||
current_time = int(datetime.utcnow().timestamp())
|
||||
if 'expires_at' in token:
|
||||
if not isinstance(token['expires_at'], int):
|
||||
try:
|
||||
token['expires_at'] = int(float(token['expires_at']))
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Converted existing expires_at to int: {token['expires_at']}")
|
||||
except (ValueError, TypeError):
|
||||
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): Could not convert existing expires_at '{token['expires_at']}' to int. Recalculating if possible.")
|
||||
if 'expires_in' in token and isinstance(token['expires_in'], (int, float)):
|
||||
token['expires_at'] = current_time + int(token['expires_in']) - 30
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Recalculated expires_at from expires_in: {token['expires_at']}")
|
||||
else:
|
||||
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Cannot determine expires_at. Original problematic value: {token['expires_at']}")
|
||||
elif 'expires_in' in token and isinstance(token['expires_in'], (int, float)):
|
||||
token['expires_at'] = current_time + int(token['expires_in']) - 30
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Calculated expires_at from expires_in: {token['expires_at']}")
|
||||
elif hasattr(current_user, 'spotify_token_expires_at') and current_user.spotify_token_expires_at:
|
||||
token['expires_at'] = int(current_user.spotify_token_expires_at.timestamp())
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Used expires_at from current_user.spotify_token_expires_at: {token['expires_at']}")
|
||||
else:
|
||||
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): expires_at missing and cannot be calculated.")
|
||||
|
||||
if 'token_type' not in token or not token.get('token_type'):
|
||||
token['token_type'] = 'Bearer'
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Set token_type to Bearer.")
|
||||
|
||||
if 'expires_in' in token:
|
||||
del token['expires_in']
|
||||
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Final token prepared for Authlib: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'token_type': '{token.get('token_type')}', 'scope': {token.get('scope')}}}")
|
||||
return token
|
||||
except json.JSONDecodeError:
|
||||
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Failed to decode token JSON: {token_str[:100]}...")
|
||||
return None
|
||||
except Exception as e:
|
||||
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Error processing token: {str(e)}", exc_info=True)
|
||||
return None
|
||||
else:
|
||||
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): No token string found in DB.")
|
||||
return None
|
||||
app.logger.debug(f"_app_fetch_token: User not authenticated or service not matched for '{name}'.")
|
||||
return None
|
||||
|
||||
def _app_update_token(name, token, refresh_token=None, access_token=None):
|
||||
app.logger.debug(f"_app_update_token: Called for service: {name}, user: {current_user.id if current_user.is_authenticated else 'Unauthenticated'}")
|
||||
if name == 'spotify':
|
||||
if current_user.is_authenticated:
|
||||
app.logger.info(f"_app_update_token for Spotify (user {current_user.id}): Received new token data to update. Keys: {list(token.keys()) if token else 'None'}")
|
||||
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Full new token: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'token_type': '{token.get('token_type')}', 'scope': {token.get('scope')}}}")
|
||||
|
||||
current_user.spotify_token = json.dumps(token)
|
||||
|
||||
if 'expires_at' in token and token['expires_at'] is not None and hasattr(current_user, 'spotify_token_expires_at'):
|
||||
try:
|
||||
current_user.spotify_token_expires_at = datetime.fromtimestamp(int(token['expires_at']))
|
||||
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Updated spotify_token_expires_at to {current_user.spotify_token_expires_at}")
|
||||
except (TypeError, ValueError) as e:
|
||||
app.logger.warning(f"_app_update_token for Spotify (user {current_user.id}): Could not update spotify_token_expires_at from token's expires_at ('{token['expires_at']}'): {str(e)}")
|
||||
|
||||
if 'refresh_token' in token and token['refresh_token'] and hasattr(current_user, 'spotify_refresh_token'):
|
||||
current_user.spotify_refresh_token = token['refresh_token']
|
||||
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Updated spotify_refresh_token.")
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
app.logger.info(f"_app_update_token for Spotify (user {current_user.id}): Token successfully updated and committed to DB.")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
app.logger.error(f"_app_update_token for Spotify (user {current_user.id}): Error committing token to DB: {str(e)}", exc_info=True)
|
||||
else:
|
||||
app.logger.warning(f"_app_update_token for Spotify: Attempted to update token for unauthenticated user.")
|
||||
# Add similar blocks for other services if needed
|
||||
|
||||
# Initialize OAuth providers (Google, Authentik, Spotify via Authlib)
|
||||
from musicround.helpers.auth_helpers import init_oauth
|
||||
init_oauth(app)
|
||||
|
||||
# Initialize Spotify client for common API access
|
||||
# This will be available for any authenticated route
|
||||
if app.config['SPOTIFY_CLIENT_ID'] and app.config['SPOTIFY_CLIENT_SECRET']:
|
||||
app.config['sp_oauth'] = SpotifyOAuth(
|
||||
client_id=app.config['SPOTIFY_CLIENT_ID'],
|
||||
client_secret=app.config['SPOTIFY_CLIENT_SECRET'],
|
||||
redirect_uri=app.config['SPOTIFY_REDIRECT_URI'],
|
||||
scope=app.config['SPOTIFY_SCOPE']
|
||||
)
|
||||
# Create a Spotify client that will be used throughout the app
|
||||
app.config['sp'] = spotipy.Spotify(auth_manager=app.config['sp_oauth'])
|
||||
|
||||
# Initialize Deezer client - import inside the function to avoid circular dependency
|
||||
init_oauth(app) # This will use the imported oauth object
|
||||
|
||||
# Manually register token handling functions
|
||||
app.logger.info(f"Attempting to manually register token functions. oauth object id: {id(oauth)}")
|
||||
if hasattr(oauth, 'tokengetter') and callable(oauth.tokengetter):
|
||||
oauth.tokengetter(_app_fetch_token)
|
||||
app.logger.info("SUCCESS: Manually registered _app_fetch_token using oauth.tokengetter().")
|
||||
else:
|
||||
app.logger.error("FAILURE: oauth.tokengetter method not found or not callable.")
|
||||
# Fallback for extreme cases - not recommended for production
|
||||
if isinstance(oauth, object) and hasattr(oauth, '_fetch_token_funcs') and isinstance(oauth._fetch_token_funcs, dict): # Basic check
|
||||
oauth._fetch_token_funcs['_app_fetch_token'] = _app_fetch_token
|
||||
app.logger.warning("MANUAL HACK: Injected _app_fetch_token into oauth._fetch_token_funcs.")
|
||||
else:
|
||||
app.logger.error("CRITICAL FAILURE: Cannot register fetch token function via method or hack.")
|
||||
|
||||
|
||||
if hasattr(oauth, 'tokenupdater') and callable(oauth.tokenupdater):
|
||||
oauth.tokenupdater(_app_update_token)
|
||||
app.logger.info("SUCCESS: Manually registered _app_update_token using oauth.tokenupdater().")
|
||||
else:
|
||||
app.logger.error("FAILURE: oauth.tokenupdater method not found or not callable.")
|
||||
if isinstance(oauth, object) and hasattr(oauth, '_update_token_funcs') and isinstance(oauth._update_token_funcs, dict): # Basic check
|
||||
oauth._update_token_funcs['_app_update_token'] = _app_update_token
|
||||
app.logger.warning("MANUAL HACK: Injected _app_update_token into oauth._update_token_funcs.")
|
||||
else:
|
||||
app.logger.error("CRITICAL FAILURE: Cannot register update token function via method or hack.")
|
||||
|
||||
# Initialize Deezer client - import inside the function to avoid circular dependency
|
||||
from musicround.deezer_client import DeezerClient
|
||||
app.config['deezer'] = DeezerClient()
|
||||
|
||||
# Add before_request handler to ensure Spotify token is available
|
||||
@app.before_request
|
||||
def ensure_spotify_token():
|
||||
"""
|
||||
Ensure a valid Spotify token is available in the session.
|
||||
Priority:
|
||||
1. Use existing manual bearer token if present in session
|
||||
2. Try to refresh user's token if they have a refresh token
|
||||
3. Use client credentials flow as fallback (no user login required)
|
||||
"""
|
||||
# Skip for static files and certain paths
|
||||
if request.path.startswith('/static') or request.path.startswith('/favicon.ico'):
|
||||
return
|
||||
|
||||
# If we already have a manual token in session, don't do anything
|
||||
# Manual tokens take priority over everything else
|
||||
if 'access_token' in session and session.get('token_source') != 'user' and session.get('token_source') != 'client_credentials':
|
||||
app.logger.debug("Using existing manual bearer token")
|
||||
return
|
||||
|
||||
from datetime import datetime
|
||||
from spotipy.oauth2 import SpotifyOAuth
|
||||
from .models import SystemSetting
|
||||
import base64
|
||||
import requests
|
||||
|
||||
try:
|
||||
# Only check user token if user is logged in
|
||||
if current_user.is_authenticated:
|
||||
# Step 1: Try to use user's refresh token
|
||||
if current_user.spotify_refresh_token:
|
||||
app.logger.debug(f"Attempting to refresh token for user {current_user.username}")
|
||||
|
||||
# Create OAuth manager for token refresh
|
||||
sp_oauth = SpotifyOAuth(
|
||||
client_id=app.config['SPOTIFY_CLIENT_ID'],
|
||||
client_secret=app.config['SPOTIFY_CLIENT_SECRET'],
|
||||
redirect_uri=url_for('users.spotify_callback', _external=True),
|
||||
scope=app.config['SPOTIFY_SCOPE']
|
||||
)
|
||||
|
||||
try:
|
||||
# Refresh user's token
|
||||
token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token)
|
||||
|
||||
if token_info and 'access_token' in token_info:
|
||||
# Update user's tokens in database
|
||||
current_user.spotify_token = token_info['access_token']
|
||||
current_user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at'])
|
||||
|
||||
# If we got a new refresh token (rare but possible), update it
|
||||
if 'refresh_token' in token_info:
|
||||
current_user.spotify_refresh_token = token_info['refresh_token']
|
||||
|
||||
# Save to database
|
||||
db.session.commit()
|
||||
|
||||
# Store token in session
|
||||
session['access_token'] = token_info['access_token']
|
||||
session['token_source'] = 'user'
|
||||
|
||||
app.logger.debug(f"Generated new token for user {current_user.username}")
|
||||
return
|
||||
except Exception as e:
|
||||
app.logger.warning(f"Failed to refresh user token: {str(e)}")
|
||||
|
||||
# Step 2: If no user token or user not logged in, use client credentials flow
|
||||
# Check if we already have a valid client credentials token
|
||||
client_token_expiry = session.get('client_token_expiry', 0)
|
||||
|
||||
if 'access_token' in session and session.get('token_source') == 'client_credentials' and client_token_expiry > datetime.now().timestamp():
|
||||
app.logger.debug("Using existing client credentials token")
|
||||
return
|
||||
|
||||
# Get client credentials from config
|
||||
client_id = app.config['SPOTIFY_CLIENT_ID']
|
||||
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
|
||||
|
||||
if client_id and client_secret:
|
||||
app.logger.debug("Getting new token via client credentials flow")
|
||||
|
||||
# Encode client credentials
|
||||
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
|
||||
# Prepare headers and payload
|
||||
headers = {
|
||||
'Authorization': f'Basic {auth_header}',
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
}
|
||||
data = {
|
||||
'grant_type': 'client_credentials'
|
||||
}
|
||||
|
||||
try:
|
||||
# Make the POST request
|
||||
response = requests.post('https://accounts.spotify.com/api/token', headers=headers, data=data)
|
||||
response.raise_for_status()
|
||||
|
||||
token_data = response.json()
|
||||
|
||||
if 'access_token' in token_data:
|
||||
# Store the token in session
|
||||
session['access_token'] = token_data['access_token']
|
||||
session['token_source'] = 'client_credentials'
|
||||
|
||||
# Calculate and store expiry time (typically 1 hour from now)
|
||||
expires_in = token_data.get('expires_in', 3600) # Default to 1 hour
|
||||
expiry_timestamp = datetime.now().timestamp() + expires_in
|
||||
session['client_token_expiry'] = expiry_timestamp
|
||||
|
||||
app.logger.debug("Successfully obtained client credentials token")
|
||||
return
|
||||
else:
|
||||
app.logger.warning("No access token in client credentials response")
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error getting client credentials token: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
app.logger.error(f"Error in ensure_spotify_token: {str(e)}")
|
||||
pass # Continue without a token if all methods fail
|
||||
|
||||
# Register blueprints
|
||||
from musicround.routes.core import core_bp
|
||||
from musicround.routes.users import users_bp
|
||||
|
||||
Reference in New Issue
Block a user