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
+125 -140
View File
@@ -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