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
+77 -14
View File
@@ -6,10 +6,10 @@ from musicround.models import Song, Tag, SongTag, db, Round
from musicround.helpers.metadata import get_song_metadata_by_isrc
from flask_wtf.csrf import CSRFProtect
import traceback # Add import at the top
import spotipy as spotify # Changed import from spotify to spotipy as spotify
import logging
from sqlalchemy import or_
from flask_login import login_required # Add import for login_required
import requests # Import requests for direct API calls
api_bp = Blueprint('api', __name__, url_prefix='/api')
@@ -421,15 +421,25 @@ def get_songs_by_tag(tag_id):
def get_spotify_album(album_id):
try:
# Check for Spotify access token
if 'access_token' not in session:
return jsonify({'error': 'You must be logged in to access this feature'}), 401
if 'spotify_token' not in session: # Assuming token will be stored as 'spotify_token' in session
return jsonify({'error': 'Spotify token not found in session. Please authenticate with Spotify.'}), 401
# Initialize Spotify client with access token
sp = spotify.Spotify(auth=session.get('access_token'))
access_token = session['spotify_token']
headers = {
'Authorization': f'Bearer {access_token}'
}
# Get album details
album = sp.album(album_id)
album_tracks = sp.album_tracks(album_id, limit=50)
album_url = f'https://api.spotify.com/v1/albums/{album_id}'
album_response = requests.get(album_url, headers=headers)
album_response.raise_for_status() # Raise an exception for HTTP errors
album = album_response.json()
# Get album tracks
album_tracks_url = f'https://api.spotify.com/v1/albums/{album_id}/tracks?limit=50'
album_tracks_response = requests.get(album_tracks_url, headers=headers)
album_tracks_response.raise_for_status()
album_tracks = album_tracks_response.json()
# Format tracks
tracks = []
@@ -454,27 +464,38 @@ def get_spotify_album(album_id):
}
return jsonify(album_data)
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error fetching Spotify album: {http_err} - {http_err.response.text}")
return jsonify({'error': f'Spotify API error: {http_err.response.status_code}', 'details': http_err.response.json() if http_err.response.content else None}), http_err.response.status_code
except Exception as e:
current_app.logger.error(f"Error fetching Spotify album: {str(e)}")
current_app.logger.error(f"Full traceback: {traceback.format_exc()}")
return jsonify({'error': 'Unable to fetch album details'}), 500
@api_bp.route('/spotify/playlist/<playlist_id>', methods=['GET'])
def get_spotify_playlist(playlist_id):
try:
# Check for Spotify access token
if 'access_token' not in session:
return jsonify({'error': 'You must be logged in to access this feature'}), 401
# Initialize Spotify client with access token
sp = spotify.Spotify(auth=session.get('access_token'))
if 'spotify_token' not in session: # Assuming token will be stored as 'spotify_token' in session
return jsonify({'error': 'Spotify token not found in session. Please authenticate with Spotify.'}), 401
access_token = session['spotify_token']
headers = {
'Authorization': f'Bearer {access_token}'
}
# Get playlist details
playlist = sp.playlist(playlist_id)
playlist_url = f'https://api.spotify.com/v1/playlists/{playlist_id}'
playlist_response = requests.get(playlist_url, headers=headers)
playlist_response.raise_for_status()
playlist = playlist_response.json()
# Format tracks
tracks = []
# Spotify API for playlist items might be paginated, this example fetches first page
# A more robust solution would handle pagination if necessary
for item in playlist['tracks']['items']:
if not item['track']:
if not item['track']: # Handle cases where track might be None (e.g., local files in playlist)
continue
track = item['track']
@@ -498,10 +519,52 @@ def get_spotify_playlist(playlist_id):
}
return jsonify(playlist_data)
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error fetching Spotify playlist: {http_err} - {http_err.response.text}")
return jsonify({'error': f'Spotify API error: {http_err.response.status_code}', 'details': http_err.response.json() if http_err.response.content else None}), http_err.response.status_code
except Exception as e:
current_app.logger.error(f"Error fetching Spotify playlist: {str(e)}")
current_app.logger.error(f"Full traceback: {traceback.format_exc()}")
return jsonify({'error': 'Unable to fetch playlist details'}), 500
@api_bp.route('/spotify/search', methods=['GET'])
@login_required
def spotify_search():
query = request.args.get('q', '')
search_type = request.args.get('type', 'track,artist,album') # Default to searching for tracks, artists, and albums
limit = request.args.get('limit', 20)
if not query:
return jsonify({"error": "Search query cannot be empty"}), 400
if 'spotify_token' not in session:
return jsonify({'error': 'Spotify token not found in session. Please authenticate with Spotify.'}), 401
access_token = session['spotify_token']
headers = {
'Authorization': f'Bearer {access_token}'
}
params = {
'q': query,
'type': search_type,
'limit': limit
}
try:
search_url = 'https://api.spotify.com/v1/search'
response = requests.get(search_url, headers=headers, params=params)
response.raise_for_status() # Raise an exception for HTTP errors
search_results = response.json()
return jsonify(search_results)
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error during Spotify search: {http_err} - {http_err.response.text}")
return jsonify({'error': f'Spotify API error: {http_err.response.status_code}', 'details': http_err.response.json() if http_err.response.content else None}), http_err.response.status_code
except Exception as e:
current_app.logger.error(f"Error during Spotify search: {str(e)}")
current_app.logger.error(f"Full traceback: {traceback.format_exc()}")
return jsonify({'error': 'Unable to perform Spotify search'}), 500
@api_bp.route('/deezer/album/<album_id>', methods=['GET'])
def get_deezer_album(album_id):
try:
+54 -142
View File
@@ -4,10 +4,12 @@ Authentication routes for the Music Round application
import os
from flask import Blueprint, render_template, redirect, url_for, request, flash, current_app, session
from flask_login import login_user, current_user, logout_user, login_required
from werkzeug.security import check_password_hash
from werkzeug.security import check_password_hash, generate_password_hash
from musicround.models import User, db
from datetime import datetime
import spotipy
import requests
import secrets
from musicround.helpers.auth_helpers import oauth, find_or_create_user, update_oauth_tokens, get_spotify_user_info
# Create blueprint
auth_bp = Blueprint('auth', __name__)
@@ -26,154 +28,64 @@ def login():
@auth_bp.route('/login-with-spotify')
def login_with_spotify():
"""Start Spotify OAuth flow for login"""
# If user is already logged in, redirect to home
"""Start Spotify OAuth flow for login using Authlib."""
if current_user.is_authenticated:
return redirect(url_for('core.index'))
if not current_app.config.get('SPOTIFY_CLIENT_ID') or not current_app.config.get('SPOTIFY_CLIENT_SECRET'):
flash('Spotify login is not configured.', 'danger')
return redirect(url_for('users.login'))
# The redirect URI should point to *this* blueprint's callback
redirect_uri = url_for('auth.callback', _external=True)
# Create a new OAuth object
sp_oauth = current_app.config['sp_oauth']
# Get the authorization URL
auth_url = sp_oauth.get_authorize_url()
# Store state in session for validation
session['oauth_state'] = sp_oauth.state
# Set flag that we're using OAuth for login, not just connection
session['spotify_login_flow'] = True
return redirect(auth_url)
# Ensure 'show_dialog': 'true' is part of authorize_params in auth_helpers.py
# when registering the Spotify client.
return oauth.spotify.authorize_redirect(redirect_uri)
@auth_bp.route('/callback')
def callback():
"""Handle Spotify OAuth callback for login"""
"""Handle Spotify OAuth callback for login using Authlib."""
try:
# Verify the state parameter
if request.args.get('state') != session.get('oauth_state'):
flash("Authentication state mismatch. Please try logging in again.", "danger")
return redirect(url_for('auth.index'))
# Get the authorization code
code = request.args.get('code')
if not code:
flash("No authorization code received from Spotify.", "danger")
return redirect(url_for('auth.index'))
# Exchange the code for an access token
sp_oauth = current_app.config['sp_oauth']
token_info = sp_oauth.get_access_token(code)
if not token_info or 'access_token' not in token_info:
flash("Failed to obtain access token from Spotify.", "danger")
return redirect(url_for('auth.index'))
# Store the token in the session
session['access_token'] = token_info['access_token']
session['refresh_token'] = token_info.get('refresh_token')
session['token_expiration'] = token_info.get('expires_at')
session['token_source'] = 'user'
# Get user info from Spotify to find or create the user account
sp = spotipy.Spotify(auth=token_info['access_token'])
spotify_user_info = sp.current_user()
if not spotify_user_info or 'id' not in spotify_user_info:
flash("Could not fetch user information from Spotify.", "danger")
return redirect(url_for('auth.index'))
spotify_id = spotify_user_info['id']
email = spotify_user_info.get('email')
display_name = spotify_user_info.get('display_name', spotify_id)
# Log the Spotify login attempt
current_app.logger.info(f"Spotify login attempt: ID={spotify_id}, Email={email}, Name={display_name}")
# Look for an existing user with this Spotify ID
user = User.query.filter_by(oauth_id=spotify_id).first()
# If no user found with this Spotify ID but we have an email, try to find by email
if not user and email:
user = User.query.filter_by(email=email).first()
if user:
# Update the user's Spotify ID if they have an account with the same email
user.oauth_id = spotify_id
current_app.logger.info(f"Linked Spotify ID {spotify_id} to existing account: {user.username}")
# If we still don't have a user, create a new one
token = oauth.spotify.authorize_access_token()
current_app.logger.debug(f"Spotify token received for login: {token}")
# Fetch user info using the token
spotify_info = get_spotify_user_info(token)
if not spotify_info or not spotify_info.get('id'):
flash('Could not fetch Spotify user information. Please try again.', 'danger')
current_app.logger.error(f"Failed to get Spotify user info for login. Response: {spotify_info}")
return redirect(url_for('users.login'))
# Find or create user based on Spotify profile
# This function needs to handle new user creation if they don't exist
# or link to an existing user if email matches, etc.
user = find_or_create_user(spotify_info, 'spotify')
if not user:
if not email:
# If Spotify didn't provide an email, we can't create a new user automatically
flash("Your Spotify account does not have an email address. Please register manually.", "danger")
return redirect(url_for('users.register'))
# Generate a unique username based on Spotify display name
base_username = ''.join(c for c in display_name if c.isalnum()).lower()
if not base_username:
base_username = "spotify_user"
username = base_username
count = 1
while User.query.filter_by(username=username).first():
username = f"{base_username}{count}"
count += 1
# Create a new user
from werkzeug.security import generate_password_hash
import secrets
# Generate a random password - user can reset it later
random_password = secrets.token_urlsafe(12)
user = User(
username=username,
email=email,
password_hash=generate_password_hash(random_password),
first_name=display_name.split()[0] if ' ' in display_name else display_name,
last_name=' '.join(display_name.split()[1:]) if ' ' in display_name else '',
oauth_id=spotify_id,
created_at=datetime.now(),
last_login=datetime.now()
)
try:
db.session.add(user)
db.session.commit()
current_app.logger.info(f"Created new user from Spotify: {username} (ID: {user.id})")
flash(f"Welcome! A new account has been created for you as '{username}'.", "success")
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating user from Spotify: {e}")
flash("Error creating account. Please try again or register manually.", "danger")
return redirect(url_for('users.register'))
# Store the Spotify tokens in the user's account
user.spotify_token = token_info['access_token']
user.spotify_refresh_token = token_info.get('refresh_token')
if 'expires_at' in token_info:
user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at'])
# Update last login time
user.last_login = datetime.now()
try:
db.session.commit()
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error updating user with Spotify tokens: {e}")
flash("Error updating your account with Spotify information.", "danger")
flash('Could not sign in with Spotify. If you are a new user, registration might be disabled. Please try again or contact support.', 'danger')
current_app.logger.error(f"Failed to find or create user for Spotify login: {spotify_info.get('email')}")
return redirect(url_for('users.login'))
# Log the user in
login_user(user)
# Update the Spotify client with the new token
current_app.config['sp'].set_auth(token_info['access_token'])
flash("Successfully logged in with Spotify!", "success")
return redirect(url_for('core.index'))
# Update tokens in the User model
if update_oauth_tokens(user, token, 'spotify'):
login_user(user) # Log in the user
user.last_login = datetime.now()
db.session.commit()
flash('Successfully logged in with Spotify!', 'success')
current_app.logger.info(f"User {user.username} logged in via Spotify ({spotify_info.get('name')})")
next_page = request.args.get('next') or session.pop('next_url', None)
if not next_page or not next_page.startswith('/'):
next_page = url_for('core.index')
return redirect(next_page)
else:
flash('Failed to store Spotify tokens. Please try again.', 'danger')
current_app.logger.error(f"Failed to update Spotify tokens for user {user.username} during login.")
return redirect(url_for('users.login'))
except Exception as e:
current_app.logger.error(f"Error during Spotify callback: {e}")
flash("Error during Spotify authentication. Please try again.", "danger")
return redirect(url_for('auth.index'))
current_app.logger.error(f"Error in Spotify login callback: {str(e)}")
flash(f'An error occurred during Spotify login: {str(e)}.', 'danger')
return redirect(url_for('users.login'))
+222 -230
View File
@@ -1,9 +1,19 @@
"""
Core routes that form the basic navigation structure of the app.
Core routes for the Music Round application
"""
from flask import Blueprint, render_template, redirect, url_for, current_app, request, send_from_directory, abort, session
from flask_login import current_user, login_required
from musicround import db
import os
import json
import time
import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, session, jsonify, abort, send_from_directory
from flask_login import login_required, current_user
from musicround.models import db, Round, Song
from musicround.config import Config
import requests
import traceback
from musicround.helpers.auth_helpers import oauth, update_oauth_tokens
from musicround.helpers.spotify_helper import get_spotify_token, get_spotify_user_info
from datetime import datetime
core_bp = Blueprint('core', __name__)
@@ -35,249 +45,239 @@ def search():
@core_bp.route('/search-results', methods=['POST'])
@login_required
def search_results():
"""Process Spotify search and display results"""
if 'access_token' not in session:
# Redirect to Spotify login if not authenticated
return redirect(url_for('auth.spotify_login'))
"""Process Spotify search and display results using Authlib"""
if not current_user.spotify_token:
current_app.logger.warning(f"User {current_user.id} does not have a Spotify token for search.")
flash("Please connect your Spotify account to search.", "warning")
return redirect(url_for('users.spotify_auth'))
# Prepare Authlib token object from current_user
expires_at_timestamp = None
if current_user.spotify_token_expiry:
if isinstance(current_user.spotify_token_expiry, datetime):
expires_at_timestamp = int(current_user.spotify_token_expiry.timestamp())
else:
try: # Should be a datetime object from DB, but being defensive
expires_at_timestamp = int(datetime.fromisoformat(str(current_user.spotify_token_expiry)).timestamp())
except ValueError:
current_app.logger.warning(f"Could not parse spotify_token_expiry for user {current_user.id}.")
authlib_token = {
'access_token': current_user.spotify_token,
'refresh_token': current_user.spotify_refresh_token,
'token_type': 'Bearer',
'expires_at': expires_at_timestamp
}
if current_user.spotify_token_expiry and current_user.spotify_token_expiry < datetime.now():
current_app.logger.info(f"User {current_user.id}'s Spotify token appears expired. Authlib will attempt refresh.")
search_api_url = 'https://api.spotify.com/v1/search'
search_term = request.form.get('search_term', '')
if not search_term:
return redirect(url_for('core.search'))
try:
# Initialize Spotify client with access token
import spotipy
from spotipy.exceptions import SpotifyException
current_app.logger.info(f"Searching Spotify for: '{search_term}' for user {current_user.id}")
current_app.logger.info(f"Searching Spotify for: {search_term}")
# Try to check if token is valid before using it
try:
sp = spotipy.Spotify(auth=session.get('access_token'))
# Make a simple API call to verify token
sp.current_user()
except SpotifyException as e:
# If token is expired, try refreshing it
if e.http_status == 401:
current_app.logger.info("Spotify token expired, attempting refresh")
# Check if we have a refresh token
if current_user.spotify_refresh_token:
try:
# Create OAuth object to refresh token
from spotipy.oauth2 import SpotifyOAuth
from musicround.config import Config
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
import datetime
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 = spotipy.Spotify(auth=token_info['access_token'])
except Exception as refresh_error:
current_app.logger.error(f"Error refreshing Spotify token: {str(refresh_error)}")
# Redirect to login if we can't refresh
return redirect(url_for('auth.spotify_login'))
else:
# No refresh token, redirect to login
return redirect(url_for('auth.spotify_login'))
else:
# Some other Spotify error
raise
# Prepare more specific search parameters for better results
# Try different search strategies for artists vs tracks
search_strategies = [
# Regular search for all types
{'q': search_term, 'type': 'track,album,playlist', 'limit': 10},
# Search specifically for artist
{'q': f'artist:{search_term}', 'type': 'track', 'limit': 10},
# Search specifically for track
{'q': f'track:{search_term}', 'type': 'track', 'limit': 10}
{'q': f'artist:{search_term}', 'type': 'track,album,playlist', 'limit': 10},
{'q': f'track:{search_term}', 'type': 'track,album,playlist', 'limit': 10},
{'q': search_term, 'type': 'track,album,playlist', 'limit': 10, 'market': 'US'},
{'q': f'{search_term}', 'type': 'track,album,playlist', 'limit': 20, 'include_external': 'audio'}
]
tracks = []
albums = []
playlists = []
# Try different search strategies until we get results
for strategy in search_strategies:
current_app.logger.info(f"Trying search strategy: {strategy}")
results_found = False
for strategy_params in search_strategies:
current_app.logger.info(f"Trying search strategy: {strategy_params} for user {current_user.id}")
try:
response = oauth.spotify.get(search_api_url, params=strategy_params, token=authlib_token)
response.raise_for_status()
results = response.json()
# Check if the token was refreshed by Authlib
# The new token would be in oauth.spotify.token
if oauth.spotify.token and oauth.spotify.token.get('access_token') != authlib_token.get('access_token'):
current_app.logger.info(f"Spotify token refreshed for user {current_user.id}.")
if update_oauth_tokens(current_user, oauth.spotify.token, 'spotify'):
# Update the local authlib_token variable to use the new token for subsequent requests in this function
authlib_token = oauth.spotify.token
current_app.logger.info(f"Refreshed Spotify token saved and authlib_token updated for user {current_user.id}.")
else:
current_app.logger.error(f"Failed to save refreshed Spotify token for user {current_user.id}.")
if results:
if 'tracks' in results and results['tracks']['items']:
results_found = True
for item in results['tracks']['items']:
if item is None or 'id' not in item or 'artists' not in item or 'album' not in item:
continue
try:
artist_names = [artist['name'] for artist in item['artists']]
image_url = None
if 'album' in item and item['album'] and 'images' in item['album'] and item['album']['images']:
image_url = item['album']['images'][0]['url']
album_name = item['album']['name'] if 'album' in item and item['album'] and 'name' in item['album'] else 'Unknown Album'
tracks.append({
'id': item['id'], 'name': item['name'], 'artist': ', '.join(artist_names),
'album': album_name, 'image_url': image_url,
'preview_url': item.get('preview_url'), 'duration_ms': item.get('duration_ms', 0)
})
except Exception as item_error:
current_app.logger.error(f"Error processing track item: {str(item_error)} for item {item}")
if 'albums' in results and results['albums']['items']:
results_found = True
for item in results['albums']['items']:
if item is None or 'id' not in item or 'artists' not in item:
continue
try:
artist_names = [artist['name'] for artist in item['artists']]
image_url = None
if 'images' in item and item['images']:
image_url = item['images'][0]['url']
albums.append({
'id': item['id'], 'name': item['name'], 'artist': ', '.join(artist_names),
'image_url': image_url, 'total_tracks': item.get('total_tracks', 0)
})
except Exception as item_error:
current_app.logger.error(f"Error processing album item: {str(item_error)} for item {item}")
if 'playlists' in results and results['playlists']['items']:
results_found = True
for item in results['playlists']['items']:
if item is None or 'id' not in item or 'owner' not in item:
continue
try:
image_url = None
if 'images' in item and item['images']:
image_url = item['images'][0]['url']
track_count = item['tracks']['total'] if 'tracks' in item and item['tracks'] else 0
owner_name = item['owner'].get('display_name') or item['owner'].get('id', 'Unknown')
playlists.append({
'id': item['id'], 'name': item['name'], 'owner': owner_name,
'image_url': image_url, 'tracks': track_count
})
except Exception as item_error:
current_app.logger.error(f"Error processing playlist item: {str(item_error)} for item {item}")
if results_found:
current_app.logger.info(f"Results found with strategy: {strategy_params}")
break
# Perform search with current strategy
results = sp.search(**strategy)
# Extract track results if available
if 'tracks' in results and results['tracks']['items']:
for item in results['tracks']['items']:
# Skip None items or items without required fields
if item is None or 'id' not in item or 'artists' not in item or 'album' not in item:
continue
artist_names = [artist['name'] for artist in item['artists']]
# Get album image safely
image_url = None
if 'album' in item and item['album'] and 'images' in item['album'] and item['album']['images']:
image_url = item['album']['images'][0]['url'] if item['album']['images'] else None
# Get album name safely
album_name = item['album']['name'] if 'album' in item and item['album'] and 'name' in item['album'] else 'Unknown Album'
tracks.append({
'id': item['id'],
'name': item['name'],
'artist': ', '.join(artist_names),
'album': album_name,
'image_url': image_url,
'preview_url': item.get('preview_url'),
'duration_ms': item.get('duration_ms', 0)
})
# Extract album results if available
if 'albums' in results and results['albums']['items']:
for item in results['albums']['items']:
# Skip None items or items without required fields
if item is None or 'id' not in item or 'artists' not in item:
continue
artist_names = [artist['name'] for artist in item['artists']]
# Get album image safely
image_url = None
if 'images' in item and item['images']:
image_url = item['images'][0]['url'] if item['images'] else None
albums.append({
'id': item['id'],
'name': item['name'],
'artist': ', '.join(artist_names),
'image_url': image_url,
'total_tracks': item.get('total_tracks', 0)
})
# Extract playlist results if available
if 'playlists' in results and results['playlists']['items']:
for item in results['playlists']['items']:
# Skip None items or items without required fields
if item is None or 'id' not in item or 'owner' not in item:
continue
# Get playlist image safely
image_url = None
if 'images' in item and item['images']:
image_url = item['images'][0]['url'] if item['images'] else None
# Get track count safely
track_count = 0
if 'tracks' in item and item['tracks'] is not None and 'total' in item['tracks']:
track_count = item['tracks']['total']
# Get owner name safely
owner_name = item['owner'].get('display_name') or item['owner'].get('id', 'Unknown')
playlists.append({
'id': item['id'],
'name': item['name'],
'owner': owner_name,
'image_url': image_url,
'tracks': track_count
})
# If we got any results, break the loop
if tracks or albums or playlists:
break
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error with search strategy {strategy_params} for user {current_user.id}: {http_err}")
if hasattr(http_err, 'response') and http_err.response is not None:
current_app.logger.error(f"Response status: {http_err.response.status_code}, Response text: {http_err.response.text}")
if http_err.response.status_code == 401:
current_app.logger.warning(f"Spotify token invalid/expired for user {current_user.id} during search. Clearing tokens.")
current_user.spotify_token = None
current_user.spotify_refresh_token = None
current_user.spotify_token_expiry = None
current_user.spotify_id = None
db.session.commit()
flash("Your Spotify session has expired or is invalid. Please reconnect your Spotify account.", "warning")
return redirect(url_for('users.spotify_auth'))
continue
except Exception as search_error:
current_app.logger.error(f"Error with search strategy {strategy_params} for user {current_user.id}: {str(search_error)}")
current_app.logger.error(traceback.format_exc())
continue
# If still no results after all strategies, try one more approach
if not tracks and not albums and not playlists:
current_app.logger.info("No results from standard searches, trying market-specific search")
# Try a more generic search with market specification
results = sp.search(q=search_term, type='track,album,playlist', limit=10, market='US')
# Extract track results
if 'tracks' in results and results['tracks']['items']:
for item in results['tracks']['items']:
artist_names = [artist['name'] for artist in item['artists']]
tracks.append({
'id': item['id'],
'name': item['name'],
'artist': ', '.join(artist_names),
'album': item['album']['name'],
'image_url': item['album']['images'][0]['url'] if item['album']['images'] else None,
'preview_url': item['preview_url'],
'duration_ms': item['duration_ms']
})
if not results_found:
current_app.logger.info(f"No results from primary searches for user {current_user.id}, trying fallback approaches")
fallback_strategies = [
{'q': search_term, 'type': 'track,album,playlist', 'limit': 20, 'market': 'US'},
{'q': f'{search_term}*', 'type': 'track', 'limit': 20},
{'q': search_term, 'type': 'track', 'limit': 50}
]
for strategy_params in fallback_strategies:
current_app.logger.info(f"Trying fallback strategy: {strategy_params} for user {current_user.id}")
try:
response = oauth.spotify.get(search_api_url, params=strategy_params, token=authlib_token)
response.raise_for_status()
results = response.json()
# Check if the token was refreshed by Authlib
if oauth.spotify.token and oauth.spotify.token.get('access_token') != authlib_token.get('access_token'):
current_app.logger.info(f"Spotify token refreshed during fallback for user {current_user.id}.")
if update_oauth_tokens(current_user, oauth.spotify.token, 'spotify'):
# Update the local authlib_token variable
authlib_token = oauth.spotify.token
current_app.logger.info(f"Refreshed Spotify token saved (fallback) and authlib_token updated for user {current_user.id}.")
else:
current_app.logger.error(f"Failed to save refreshed Spotify token (fallback) for user {current_user.id}.")
if results:
if 'tracks' in results and results['tracks']['items']:
results_found = True
for item in results['tracks']['items']:
if item is None or 'id' not in item or 'artists' not in item:
continue
try:
artist_names = [artist.get('name', 'Unknown Artist') for artist in item.get('artists', [])]
album_name = "Unknown Album"
image_url = None
if 'album' in item and item['album']:
album_name = item['album'].get('name', 'Unknown Album')
if 'images' in item['album'] and item['album']['images']:
image_url = item['album']['images'][0].get('url')
tracks.append({
'id': item['id'], 'name': item.get('name', 'Unknown Track'),
'artist': ', '.join(artist_names), 'album': album_name, 'image_url': image_url,
'preview_url': item.get('preview_url'), 'duration_ms': item.get('duration_ms', 0)
})
except Exception as item_error:
current_app.logger.error(f"Error processing fallback track item: {str(item_error)} for item {item}")
if results_found:
current_app.logger.info(f"Results found with fallback strategy: {strategy_params}")
break
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error with fallback strategy {strategy_params} for user {current_user.id}: {http_err}")
if hasattr(http_err, 'response') and http_err.response is not None:
current_app.logger.error(f"Response status: {http_err.response.status_code}, Response text: {http_err.response.text}")
if http_err.response.status_code == 401:
current_app.logger.warning(f"Spotify token invalid/expired for user {current_user.id} during fallback. Clearing tokens.")
current_user.spotify_token = None
current_user.spotify_refresh_token = None
current_user.spotify_token_expiry = None
current_user.spotify_id = None
db.session.commit()
flash("Your Spotify session has expired or is invalid. Please reconnect your Spotify account.", "warning")
return redirect(url_for('users.spotify_auth'))
continue
except Exception as fallback_error:
current_app.logger.error(f"Error with fallback strategy {strategy_params} for user {current_user.id}: {str(fallback_error)}")
current_app.logger.error(traceback.format_exc())
continue
# Remove duplicates (in case our strategies found the same items)
unique_tracks = []
track_ids_seen = set()
for track in tracks:
if track['id'] not in track_ids_seen:
track_ids_seen.add(track['id'])
unique_tracks.append(track)
unique_tracks = list({track['id']: track for track in tracks}.values())
unique_albums = list({album['id']: album for album in albums}.values())
unique_playlists = list({playlist['id']: playlist for playlist in playlists}.values())
unique_albums = []
album_ids_seen = set()
for album in albums:
if album['id'] not in album_ids_seen:
album_ids_seen.add(album['id'])
unique_albums.append(album)
current_app.logger.info(f"Search for '{search_term}' by user {current_user.id} yielded: {len(unique_tracks)} tracks, {len(unique_albums)} albums, {len(unique_playlists)} playlists")
unique_playlists = []
playlist_ids_seen = set()
for playlist in playlists:
if playlist['id'] not in playlist_ids_seen:
playlist_ids_seen.add(playlist['id'])
unique_playlists.append(playlist)
# Log the number of results found
current_app.logger.info(f"Search results: {len(unique_tracks)} tracks, {len(unique_albums)} albums, {len(unique_playlists)} playlists")
# Render search results template
return render_template('service_search_results.html',
service_name='Spotify',
search_term=search_term,
tracks=unique_tracks,
albums=unique_albums,
playlists=unique_playlists,
service_name='Spotify', search_term=search_term,
tracks=unique_tracks, albums=unique_albums, playlists=unique_playlists,
track_import_url=url_for('import_songs.import_song'),
album_import_url=url_for('import_songs.import_album'),
playlist_import_url=url_for('import_songs.import_playlist'),
track_id_field='song_id',
album_id_field='album_id',
playlist_id_field='playlist_id',
tracks_label='Tracks',
has_preview=True,
search_url=url_for('core.search'))
track_id_field='song_id', album_id_field='album_id',
playlist_id_field='playlist_id', tracks_label='Tracks',
has_preview=True, search_url=url_for('core.search'))
except Exception as e:
# Log the detailed error
import traceback
current_app.logger.error(f"Spotify search error: {str(e)}")
current_app.logger.error(f"Generic Spotify search error for user {current_user.id} ({search_term}): {str(e)}")
current_app.logger.error(traceback.format_exc())
# Render error template
if "token" in str(e).lower() or "auth" in str(e).lower() or "401" in str(e):
flash("An authentication error occurred with Spotify. Please try reconnecting your account.", "danger")
return redirect(url_for('users.spotify_auth'))
return render_template('error.html',
error_message="An error occurred while searching Spotify.",
error_details=str(e),
@@ -291,10 +291,7 @@ def view_songs():
"""
from musicround.models import Song, Tag
# Get all songs
songs = Song.query.all()
# Get all tags
tags = Tag.query.all()
return render_template('view_songs.html', songs=songs, tags=tags)
@@ -305,19 +302,14 @@ def serve_user_audio(filepath):
"""
Serve user custom audio files from the data directory
"""
# For security, ensure the filepath doesn't try to access parent directories
if '..' in filepath:
abort(404)
# Only allow access to the current user's custom MP3 files or to admins
if 'custommp3/' in filepath:
# Extract username from the filepath
parts = filepath.split('/')
if len(parts) >= 2 and parts[0] == 'custommp3':
username = parts[1]
# Check if current user is the owner of the file or an admin
if username != current_user.username and not current_user.is_admin:
abort(403) # Unauthorized
abort(403)
return send_from_directory('/data', filepath)
+25 -62
View File
@@ -3,6 +3,8 @@ from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_login import current_user, login_required
from musicround.models import Song, Round, Tag, db
from musicround.helpers.auth_helpers import oauth
from musicround.helpers.import_helper import ImportHelper
generate_bp = Blueprint('generate', __name__)
@@ -330,39 +332,20 @@ def get_songs_from_deezer_playlist(playlist_id):
deezer_client = current_app.config['deezer']
songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10)
playlist = deezer_client.get_playlist(playlist_id)
if not playlist:
imported_songs = ImportHelper.import_item(
item_id=playlist_id,
item_type='playlist',
source='deezer',
deezer_client=deezer_client
)
if not imported_songs:
current_app.logger.warning(f"No songs returned from ImportHelper.import_item for Deezer playlist {playlist_id}")
return []
# Use the ImportHelper static methods directly without creating an instance
from musicround.helpers.import_helper import ImportHelper
songs = []
tracks = playlist.get('tracks', {}).get('data', [])
for track in tracks:
deezer_id = str(track.get('id'))
if not deezer_id:
continue
# Check if song already exists in our database
existing_song = Song.query.filter_by(deezer_id=deezer_id).first()
if existing_song:
songs.append(existing_song)
continue
# Use the proper ImportHelper static method for importing
track_result = ImportHelper.import_deezer_track(deezer_client, deezer_id)
# If the track was successfully imported, retrieve it from the database
if track_result.get('imported_count', 0) > 0:
imported_song = Song.query.filter_by(deezer_id=deezer_id).first()
if imported_song:
songs.append(imported_song)
return songs[:songs_per_round] # Limit to songs_per_round
return imported_songs[:songs_per_round]
except Exception as e:
current_app.logger.error(f"Error fetching Deezer playlist: {e}")
current_app.logger.error(f"Error fetching or importing Deezer playlist {playlist_id}: {e}")
import traceback
current_app.logger.error(traceback.format_exc())
return []
@@ -372,42 +355,22 @@ def get_songs_from_spotify_playlist(playlist_id):
Fetch songs from a Spotify playlist, properly import them with metadata, and return them
"""
try:
sp = current_app.config['sp']
songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10)
playlist = sp.playlist_tracks(playlist_id)
if not playlist:
imported_songs = ImportHelper.import_item(
item_id=playlist_id,
item_type='playlist',
source='spotify',
oauth_spotify=oauth.spotify
)
if not imported_songs:
current_app.logger.warning(f"No songs returned from ImportHelper.import_item for Spotify playlist {playlist_id}")
return []
# Use the ImportHelper static methods directly without creating an instance
from musicround.helpers.import_helper import ImportHelper
songs = []
for item in playlist.get('items', []):
track = item.get('track')
if not track or not track.get('id'):
continue
spotify_id = track.get('id')
# Check if the song already exists in our database
existing_song = Song.query.filter_by(spotify_id=spotify_id).first()
if existing_song:
songs.append(existing_song)
continue
# Use the proper ImportHelper static methods for importing
track_result = ImportHelper.import_spotify_track(sp, spotify_id)
# If the track was successfully imported, retrieve it from the database
if track_result.get('imported_count', 0) > 0:
imported_song = Song.query.filter_by(spotify_id=spotify_id).first()
if imported_song:
songs.append(imported_song)
return songs[:songs_per_round] # Limit to songs_per_round
return imported_songs[:songs_per_round]
except Exception as e:
current_app.logger.error(f"Error fetching Spotify playlist: {e}")
current_app.logger.error(f"Error fetching or importing Spotify playlist {playlist_id}: {e}")
import traceback
current_app.logger.error(traceback.format_exc())
return []
+16 -8
View File
@@ -7,15 +7,15 @@ import random
from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify
from musicround.models import Song, db
from musicround.routes.import_songs import import_pl, import_track
from musicround.helpers.auth_helpers import oauth # Import the oauth object
import_bp = Blueprint('import', __name__, url_prefix='/import')
def fetch_all_user_playlists(sp, user_id, limit=50):
def fetch_all_user_playlists(user_id, limit=50): # Removed sp argument
"""
Fetch all playlists from a specific Spotify user account with pagination
Args:
sp: Spotify API client
user_id: Spotify user ID to fetch playlists from
limit: Number of playlists to fetch per request (max 50)
@@ -32,7 +32,8 @@ def fetch_all_user_playlists(sp, user_id, limit=50):
while total is None or offset < total:
try:
# Use Spotify API to get playlists with pagination
results = sp.user_playlists(user_id, limit=limit, offset=offset)
# Use oauth.spotify instead of sp
results = oauth.spotify.get(f'users/{user_id}/playlists', params={'limit': limit, 'offset': offset}).json()
# If first request, get the total
if total is None:
@@ -101,13 +102,20 @@ def import_official_playlists():
if 'access_token' not in session:
return redirect(url_for('auth.login'))
sp = current_app.config['sp']
# Handle POST request for importing a playlist
if request.method == 'POST':
playlist_id = request.form['playlist_id']
import_pl(playlist_id)
flash('Spotify playlist imported successfully!', 'success')
from musicround.helpers.import_helper import ImportHelper
result = ImportHelper.import_item('spotify', 'playlist', playlist_id)
if result['imported_count'] > 0:
flash(f"Successfully imported {result['imported_count']} songs from playlist!", 'success')
elif result['skipped_count'] > 0 and result['error_count'] == 0:
flash(f"All {result['skipped_count']} songs were already in the database.", 'info')
elif result['error_count'] > 0:
flash(f"Playlist import completed with {result['imported_count']} new songs, {result['skipped_count']} skipped, and {result['error_count']} errors.", 'warning')
else:
flash('No songs were imported from the playlist. It might be empty or an issue occurred.', 'info')
return redirect(url_for('core.view_songs'))
# Get filter keywords from the query string (default to empty list)
@@ -164,7 +172,7 @@ def import_official_playlists():
account_start = time.time()
# Fetch all playlists for this account
account_playlists = fetch_all_user_playlists(sp, account)
account_playlists = fetch_all_user_playlists(account) # Removed sp argument
account_end = time.time()
account_debug['time_ms'] = int((account_end - account_start) * 1000)
+82 -111
View File
@@ -4,108 +4,92 @@ Import routes for the Music Round application
import json
import time
import random
from datetime import datetime # Add datetime import
from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify
from flask_login import current_user
from musicround.models import Song, db
from musicround.routes.import_songs import import_pl
from musicround.helpers.import_helper import ImportHelper
from musicround.helpers.auth_helpers import oauth
import_bp = Blueprint('import', __name__, url_prefix='/import')
def fetch_all_user_playlists(sp, user_id, limit=50):
def fetch_all_user_playlists(oauth_client, token, user_id, limit=50):
"""
Fetch all playlists from a specific Spotify user account with pagination
Fetch all playlists from a specific Spotify user account with pagination using Authlib.
Args:
sp: Spotify API client
user_id: Spotify user ID to fetch playlists from
limit: Number of playlists to fetch per request (max 50)
oauth_client: The Authlib Spotify client (e.g., oauth.spotify).
token: An Authlib token object for authentication.
user_id: Spotify user ID to fetch playlists from.
limit: Number of playlists to fetch per request (max 50).
Returns:
List of all playlists from the specified user
List of all playlists from the specified user.
"""
all_playlists = []
offset = 0
total = None
start_time = time.time()
current_app.logger.info(f"Started fetching playlists for user '{user_id}'")
current_app.logger.info(f"Started fetching playlists for user '{user_id}' using Authlib")
# Hard limit to prevent infinite loops (should never be needed if API works correctly)
max_loops = 100
max_loops = 100 # Hard limit to prevent infinite loops
loop_count = 0
while loop_count < max_loops:
loop_count += 1
try:
# Use Spotify API to get playlists with pagination
current_app.logger.info(f"Fetching playlists for {user_id} with offset={offset}, limit={limit}, loop={loop_count}")
results = sp.user_playlists(user_id, limit=limit, offset=offset)
api_url = f'https://api.spotify.com/v1/users/{user_id}/playlists'
params = {'limit': limit, 'offset': offset}
current_app.logger.info(f"Fetching playlists for {user_id} with offset={offset}, limit={limit}, loop={loop_count}")
# Use Authlib client to make the GET request
resp = oauth_client.get(api_url, token=token, params=params)
resp.raise_for_status() # Raise an exception for HTTP errors
results = resp.json()
# Log raw API response for debugging (only first few characters to avoid flooding logs)
response_sample = str(results)[:500] + '...' if len(str(results)) > 500 else str(results)
current_app.logger.debug(f"API response sample: {response_sample}")
# If first request, get and validate the total
if total is None:
total = results.get('total', 0)
current_app.logger.info(f"User '{user_id}' has {total} playlists in total according to API")
if total == 0:
current_app.logger.warning(f"API reported 0 total playlists for {user_id} - possible API error")
current_app.logger.warning(f"API reported 0 total playlists for {user_id} - possible API error or no playlists")
# Add the current batch of playlists to our collection
playlists_batch = results.get('items', [])
batch_count = len(playlists_batch)
all_playlists.extend(playlists_batch)
current_app.logger.info(f"Batch for {user_id}: offset={offset}, received={batch_count} playlists")
# If we didn't get any playlists in this batch, something is wrong
if batch_count == 0:
current_app.logger.warning(f"Received 0 playlists for {user_id} at offset {offset} - possible API error")
if 'items' not in results:
current_app.logger.warning(f"Missing 'items' key in API response for {user_id}")
if batch_count == 0 and offset < total:
current_app.logger.warning(f"Received 0 playlists for {user_id} at offset {offset} but expected more (total: {total}) - stopping.")
break
# Break if we received fewer items than requested (last page)
if batch_count < limit:
current_app.logger.info(f"Reached end of results for {user_id} (received {batch_count} < limit {limit})")
if not results.get('next'): # Spotify API uses 'next' field to indicate more pages
current_app.logger.info(f"No more 'next' URL for {user_id} at offset {offset}. Fetched {len(all_playlists)}/{total}.")
break
# Update offset for next batch
offset += batch_count
offset += batch_count # Correctly increment offset by the number of items received
# Log progress
current_app.logger.info(f"Fetched {len(playlists_batch)} playlists for '{user_id}', progress: {len(all_playlists)}/{total}")
# Break if we've reached or exceeded the total number of playlists
if offset >= total:
current_app.logger.info(f"Reached total {total} playlists for {user_id} at offset {offset}")
if len(all_playlists) >= total:
current_app.logger.info(f"Fetched all {total} playlists for {user_id}.")
break
# Break if we've exhausted all playlists (next URL is None)
if not results.get('next'):
current_app.logger.info(f"No more 'next' URL for {user_id} at offset {offset}")
# Check if we should have more results based on 'total'
if offset < total:
current_app.logger.warning(
f"API inconsistency: 'next' is None but we've only fetched {offset} out of {total} playlists"
)
break
except Exception as e:
current_app.logger.error(f"Error fetching playlists for user '{user_id}' at offset {offset}: {str(e)}")
# Try to get more specific error information
import traceback
current_app.logger.error(f"Traceback: {traceback.format_exc()}")
break
# Check if we hit the max loops limit
if loop_count >= max_loops:
current_app.logger.warning(f"Reached maximum loop count ({max_loops}) for user {user_id}")
end_time = time.time()
duration = int((end_time - start_time) * 1000)
current_app.logger.info(f"Completed fetching {len(all_playlists)}/{total} playlists for user '{user_id}' in {duration}ms")
current_app.logger.info(f"Completed fetching {len(all_playlists)}/{total if total is not None else 'unknown'} playlists for user '{user_id}' in {duration}ms")
return all_playlists
@@ -144,15 +128,22 @@ def filter_playlists_by_keywords(playlists, keywords, debug_info=None):
@import_bp.route('/official-playlists', methods=['GET', 'POST'])
def import_official_playlists():
"""Display and import official Spotify playlists from multiple regional accounts"""
if 'access_token' not in session:
return redirect(url_for('users.login'))
sp = current_app.config['sp']
# Ensure user is logged in and has a Spotify token in session or on their user object
auth_token = session.get('spotify_token') # Attempt to get token from session
if not auth_token and current_user.is_authenticated and current_user.spotify_token:
auth_token = {
'access_token': current_user.spotify_token,
'token_type': 'Bearer',
'expires_at': current_user.spotify_token_expiry.timestamp() if current_user.spotify_token_expiry else None,
'refresh_token': current_user.spotify_refresh_token
}
elif not auth_token:
flash("No active Spotify session. Please connect your Spotify account.", "warning")
return redirect(url_for('users.spotify_link'))
# Handle POST request for importing a playlist
if request.method == 'POST':
playlist_id = request.form['playlist_id']
# Use the new unified ImportHelper
result = ImportHelper.import_item('spotify', 'playlist', playlist_id)
if result['imported_count'] > 0:
@@ -194,58 +185,32 @@ def import_official_playlists():
'filtered_out': 0,
'matched_keywords': {},
'query_time_ms': 0,
'duplicates_removed': 0
'duplicates_removed': 0,
'token_source': 'user_session_or_db' if auth_token.get('access_token') == session.get('spotify_token', {}).get('access_token') or (current_user.is_authenticated and auth_token.get('access_token') == current_user.spotify_token) else 'unknown'
}
# Initialize playlists list
all_playlists = []
try:
start_time = time.time()
start_query_time = time.time()
# Process each Spotify account or just the selected one
accounts_to_process = [selected_account] if selected_account != 'all' else spotify_accounts
if selected_account == 'all':
for acc_id in spotify_accounts:
current_app.logger.info(f"Fetching playlists for official account: {acc_id}")
playlists = fetch_all_user_playlists(oauth.spotify, auth_token, acc_id)
if debug_mode:
debug_info['accounts'][acc_id] = {'fetched': len(playlists), 'filtered_in': 0, 'filtered_out': 0}
all_playlists.extend(playlists)
debug_info['total_fetched'] += len(playlists)
else:
current_app.logger.info(f"Fetching playlists for selected official account: {selected_account}")
playlists = fetch_all_user_playlists(oauth.spotify, auth_token, selected_account)
if debug_mode:
debug_info['accounts'][selected_account] = {'fetched': len(playlists), 'filtered_in': 0, 'filtered_out': 0}
all_playlists.extend(playlists)
debug_info['total_fetched'] += len(playlists)
for account in accounts_to_process:
if account not in spotify_accounts and account != 'all':
continue
account_debug = {
'total': 0,
'fetched': 0,
'filtered': 0,
'time_ms': 0
}
account_start = time.time()
# Fetch all playlists for this account
account_playlists = fetch_all_user_playlists(sp, account)
account_end = time.time()
account_debug['time_ms'] = int((account_end - account_start) * 1000)
account_debug['total'] = len(account_playlists)
account_debug['fetched'] = len(account_playlists)
debug_info['total_fetched'] += len(account_playlists)
# Apply keyword filtering if keywords provided
if filter_keywords:
filtered_playlists = filter_playlists_by_keywords(
account_playlists,
filter_keywords,
debug_info
)
account_debug['filtered'] = len(filtered_playlists)
debug_info['filtered_out'] += (len(account_playlists) - len(filtered_playlists))
all_playlists.extend(filtered_playlists)
else:
# No filtering, use all playlists
all_playlists.extend(account_playlists)
account_debug['filtered'] = len(account_playlists)
debug_info['accounts'][account] = account_debug
# Remove duplicates based on playlist ID
unique_playlists = []
seen_ids = set()
@@ -259,8 +224,8 @@ def import_official_playlists():
all_playlists = unique_playlists
debug_info['total_filtered'] = len(all_playlists)
end_time = time.time()
debug_info['query_time_ms'] = int((end_time - start_time) * 1000)
end_query_time = time.time()
debug_info['query_time_ms'] = int((end_query_time - start_query_time) * 1000)
# Log summary
current_app.logger.info(
@@ -496,11 +461,27 @@ def test_spotify_client():
# Test spotipy implementation
try:
sp = current_app.config['sp']
auth_token = session.get('spotify_token')
if not auth_token and current_user.is_authenticated and current_user.spotify_token:
# Attempt to build a token object compatible with Authlib from user's stored token
auth_token = {
'access_token': current_user.spotify_token, # Assuming this is the access token string
'token_type': 'Bearer', # Default token type
'expires_at': current_user.spotify_token_expiry.timestamp() if current_user.spotify_token_expiry else None,
'refresh_token': current_user.spotify_refresh_token
}
# Ensure expires_at is a Unix timestamp if present
if auth_token.get('expires_at') and isinstance(auth_token['expires_at'], datetime):
auth_token['expires_at'] = int(auth_token['expires_at'].timestamp())
if not auth_token:
raise Exception("Spotify token not found for current user or session.")
start_time = time.time()
current_app.logger.info(f"Testing spotipy implementation for account {account}")
spotipy_playlists = fetch_all_user_playlists(sp, account)
current_app.logger.info(f"Testing Authlib Spotify implementation for account {account}")
# Use the fetch_all_user_playlists function which now uses oauth.spotify
spotipy_playlists = fetch_all_user_playlists(oauth.spotify, auth_token, account)
end_time = time.time()
duration_ms = int((end_time - start_time) * 1000)
@@ -508,12 +489,7 @@ def test_spotify_client():
results['spotipy']['playlists'] = spotipy_playlists
results['spotipy']['count'] = len(spotipy_playlists)
results['spotipy']['time_ms'] = duration_ms
# Get total from first API call if available
if spotipy_playlists:
first_result = sp.user_playlists(account, limit=1)
results['spotipy']['total'] = first_result.get('total', 'unknown')
except Exception as e:
import traceback
current_app.logger.error(f"Error testing spotipy: {e}")
@@ -543,11 +519,6 @@ def test_spotify_client():
results['direct']['count'] = len(direct_playlists)
results['direct']['time_ms'] = duration_ms
# Get total if available from response
if direct_playlists and len(direct_playlists) > 0:
first_result = direct_client.user_playlists(account, limit=1)
results['direct']['total'] = first_result.get('total', 'unknown')
except Exception as e:
import traceback
current_app.logger.error(f"Error testing direct client: {e}")
+78 -32
View File
@@ -3,43 +3,59 @@ import os
import requests
import json
from flask import Blueprint, session, redirect, request, render_template, url_for, current_app, flash
from flask_login import login_required, current_user
from musicround.models import Song, db
from musicround.helpers.metadata import get_song_metadata_by_isrc
from musicround.helpers.import_helper import ImportHelper
from musicround.helpers.auth_helpers import oauth
import_songs_bp = Blueprint('import_songs', __name__, url_prefix='/import')
# Legacy function retained for backward compatibility
def import_track(track_id):
"""Legacy helper function that now uses the new ImportHelper"""
result = ImportHelper.import_item('spotify', 'track', track_id)
return result['imported_count'] > 0
result = ImportHelper.import_item(service_name='spotify', item_type='track', item_id=track_id, oauth_spotify=oauth.spotify)
return result.get('imported_count', 0) > 0
# Legacy function retained for backward compatibility
def import_pl(playlist_id):
"""Legacy helper function that now uses the new ImportHelper"""
ImportHelper.import_item('spotify', 'playlist', playlist_id)
ImportHelper.import_item(service_name='spotify', item_type='playlist', item_id=playlist_id, oauth_spotify=oauth.spotify)
# Legacy function retained for backward compatibility
def import_al(album_id):
"""Legacy helper function that now uses the new ImportHelper"""
ImportHelper.import_item('spotify', 'album', album_id)
ImportHelper.import_item(service_name='spotify', item_type='album', item_id=album_id, oauth_spotify=oauth.spotify)
@import_songs_bp.route('/song', methods=['GET', 'POST'])
@login_required
def import_song():
if 'access_token' not in session:
if not current_user.is_authenticated:
flash("Please log in to import songs.", "warning")
return redirect(url_for('users.login'))
if not current_user.spotify_token:
flash("Please connect your Spotify account to import songs.", "warning")
return redirect(url_for('users.spotify_auth'))
if request.method == 'POST':
track_id = request.form['song_id']
result = ImportHelper.import_item('spotify', 'track', track_id)
track_id = request.form.get('song_id')
if not track_id:
flash("No song ID provided for import.", "danger")
return redirect(request.referrer or url_for('core.search'))
result = ImportHelper.import_item(service_name='spotify', item_type='track', item_id=track_id, oauth_spotify=oauth.spotify)
if result['imported_count'] > 0:
flash(f'Successfully imported {result["imported_count"]} song!', 'success')
elif result['skipped_count'] > 0:
imported_count = result.get('imported_count', 0)
skipped_count = result.get('skipped_count', 0)
errors = result.get("errors", [])
if imported_count > 0:
flash(f'Successfully imported {imported_count} song!', 'success')
elif skipped_count > 0:
flash('Song was already in the database.', 'info')
else:
flash(f'Error importing song: {", ".join(result["errors"])}', 'danger')
flash(f'Error importing song: {", ".join(errors) if errors else "Unknown error"}', 'danger')
return redirect(url_for('core.view_songs'))
@@ -53,22 +69,37 @@ def import_song():
back_url=url_for('core.search'))
@import_songs_bp.route('/playlist', methods=['GET', 'POST'])
@login_required
def import_playlist():
if 'access_token' not in session:
if not current_user.is_authenticated:
flash("Please log in to import playlists.", "warning")
return redirect(url_for('users.login'))
if not current_user.spotify_token:
flash("Please connect your Spotify account to import playlists.", "warning")
return redirect(url_for('users.spotify_auth'))
if request.method == 'POST':
playlist_id = request.form['playlist_id']
result = ImportHelper.import_item('spotify', 'playlist', playlist_id)
playlist_id = request.form.get('playlist_id')
if not playlist_id:
flash("No playlist ID provided for import.", "danger")
return redirect(request.referrer or url_for('core.search'))
result = ImportHelper.import_item(service_name='spotify', item_type='playlist', item_id=playlist_id, oauth_spotify=oauth.spotify)
if result['imported_count'] > 0:
flash(f'Successfully imported {result["imported_count"]} songs from playlist!', 'success')
elif result['skipped_count'] > 0 and result['error_count'] == 0:
flash(f'All {result["skipped_count"]} songs were already in the database.', 'info')
elif result['error_count'] > 0:
flash(f'Encountered {result["error_count"]} errors during import.', 'warning')
imported_count = result.get('imported_count', 0)
skipped_count = result.get('skipped_count', 0)
error_count = result.get('error_count', 0)
errors = result.get("errors", [])
if imported_count > 0:
flash(f'Successfully imported {imported_count} songs from playlist! ({skipped_count} skipped, {error_count} errors).', 'success')
elif skipped_count > 0 and error_count == 0:
flash(f'All {skipped_count} songs were already in the database.', 'info')
elif error_count > 0:
flash(f'Playlist import: {imported_count} new, {skipped_count} skipped, {error_count} errors. Errors: {", ".join(errors)}', 'warning')
else:
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
flash(f'Error importing playlist: {", ".join(errors) if errors else "No songs imported, playlist might be empty or an unknown issue occurred."}', 'danger')
return redirect(url_for('core.view_songs'))
@@ -82,22 +113,37 @@ def import_playlist():
back_url=url_for('core.search'))
@import_songs_bp.route('/album', methods=['GET', 'POST'])
@login_required
def import_album():
if 'access_token' not in session:
if not current_user.is_authenticated:
flash("Please log in to import albums.", "warning")
return redirect(url_for('users.login'))
if not current_user.spotify_token:
flash("Please connect your Spotify account to import albums.", "warning")
return redirect(url_for('users.spotify_auth'))
if request.method == 'POST':
album_id = request.form['album_id']
result = ImportHelper.import_item('spotify', 'album', album_id)
album_id = request.form.get('album_id')
if not album_id:
flash("No album ID provided for import.", "danger")
return redirect(request.referrer or url_for('core.search'))
result = ImportHelper.import_item(service_name='spotify', item_type='album', item_id=album_id, oauth_spotify=oauth.spotify)
if result['imported_count'] > 0:
flash(f'Successfully imported {result["imported_count"]} songs from album!', 'success')
elif result['skipped_count'] > 0 and result['error_count'] == 0:
flash(f'All {result["skipped_count"]} songs were already in the database.', 'info')
elif result['error_count'] > 0:
flash(f'Encountered {result["error_count"]} errors during import.', 'warning')
imported_count = result.get('imported_count', 0)
skipped_count = result.get('skipped_count', 0)
error_count = result.get('error_count', 0)
errors = result.get("errors", [])
if imported_count > 0:
flash(f'Successfully imported {imported_count} songs from album! ({skipped_count} skipped, {error_count} errors).', 'success')
elif skipped_count > 0 and error_count == 0:
flash(f'All {skipped_count} songs were already in the database.', 'info')
elif error_count > 0:
flash(f'Album import: {imported_count} new, {skipped_count} skipped, {error_count} errors. Errors: {", ".join(errors)}', 'warning')
else:
flash(f'Error importing album: {", ".join(result["errors"])}', 'danger')
flash(f'Error importing album: {", ".join(errors) if errors else "No songs imported, album might be empty or an unknown issue occurred."}', 'danger')
return redirect(url_for('core.view_songs'))
+10 -7
View File
@@ -22,6 +22,7 @@ from musicround.models import Round, Song, db
from pydub import AudioSegment
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from musicround.helpers.auth_helpers import oauth
rounds_bp = Blueprint('rounds', __name__, url_prefix='/rounds')
@@ -37,7 +38,6 @@ def rounds_list():
def round_detail(round_id):
"""Display details of a specific round"""
rnd = Round.query.get(round_id)
sp = current_app.config['sp']
if rnd:
song_ids = rnd.songs.split(',')
@@ -49,14 +49,17 @@ def round_detail(round_id):
email_error = session.pop('email_error', None) # Retrieve and remove the error message from the session
# For sp.current_user(), we need to ensure we have a valid token if needed for this view
# For oauth.spotify, we need to ensure we have a valid token if needed for this view
user_info = None
try:
if 'access_token' in session: # Only try to get user info if we have a token
user_info = sp.current_user()
except:
# If we can't get user info, continue without it
current_app.logger.warning("Could not get Spotify user info")
if current_user.is_authenticated and current_user.spotify_token:
# Use oauth.spotify to get user info
# Ensure the token is fresh or handle potential MissingTokenError
user_info_response = oauth.spotify.get('https://api.spotify.com/v1/me')
user_info_response.raise_for_status() # Raise an exception for bad status codes
user_info = user_info_response.json()
except Exception as e: # Catch a broader range of exceptions, including MissingTokenError
current_app.logger.warning(f"Could not get Spotify user info using Authlib: {str(e)}")
return render_template('round_detail.html', round=rnd, songs=ordered_songs, user_info=user_info, email_error=email_error)
else:
+228 -271
View File
@@ -1,23 +1,62 @@
"""
User authentication and profile management routes
"""
import os
import time
import uuid
import uuid # Add missing import
import time # Add missing import
from datetime import datetime, timedelta
from flask import Blueprint, render_template, redirect, url_for, flash, request, current_app, session, jsonify
from flask_login import login_user, current_user, logout_user, login_required
from flask_login import login_user, current_user, logout_user, login_required # Ensure flask_login is imported
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy.exc import IntegrityError
from spotipy.oauth2 import SpotifyOAuth
from spotipy.exceptions import SpotifyException
from musicround.models import db, User, Role, SystemSetting
from musicround.helpers.utils import get_available_voices
from musicround.helpers.auth_helpers import oauth, find_or_create_user, update_oauth_tokens, get_google_user_info, get_authentik_user_info
from musicround.helpers.auth_helpers import oauth, find_or_create_user, update_oauth_tokens, get_google_user_info, get_authentik_user_info, get_spotify_user_info
from musicround.helpers.spotify_helper import get_spotify_token, get_current_user_spotify_token, get_spotify_user_info as spotify_helper_get_user_info
users_bp = Blueprint('users', __name__, url_prefix='/users')
# Helper function for processing Spotify account linking
def _process_spotify_link(user, token_payload, spotify_user_data):
"""Processes the linking of a Spotify account to a user."""
spotify_id = spotify_user_data['id']
# Check if this Spotify account is already linked to another user
existing_user_with_spotify_id = User.query.filter(User.spotify_id == spotify_id, User.id != user.id).first()
if existing_user_with_spotify_id:
flash(f"This Spotify account is already linked to another user ({existing_user_with_spotify_id.username}). "
"Please use a different Spotify account or log in with that user.", "danger")
return False
user.spotify_id = spotify_id
update_oauth_tokens(user, token_payload, 'spotify') # Saves token, refresh_token, expiry to user model
# Store comprehensive user info in session
session['spotify_user_info'] = spotify_user_data
db.session.commit()
flash('Your Spotify account has been successfully linked!', 'success')
current_app.logger.info(f"User {user.id} successfully linked Spotify account {spotify_id}.")
return True
# Helper function for processing Spotify account disconnection
def _process_spotify_disconnect(user):
"""Processes the disconnection of a user's Spotify account."""
current_app.logger.info(f"User {user.id} requested Spotify disconnect.")
user.spotify_id = None
user.spotify_token = None
user.spotify_refresh_token = None
user.spotify_token_expiry = None
# Clear related session variables
session.pop('spotify_display_name', None)
session.pop('spotify_user_info', None)
db.session.commit()
flash('Your Spotify account has been disconnected.', 'success')
current_app.logger.info(f"User {user.id} successfully disconnected their Spotify account.")
def admin_required(f):
from functools import wraps
@wraps(f)
@@ -393,87 +432,115 @@ def profile():
admin_exists = False
if admin_role:
admin_exists = admin_role.users.count() > 0
# Get current time for token expiry checks
# Get current time for token expiry checks
now = datetime.now()
# Get info about current tokens
system_refresh_token = SystemSetting.get('fallback_spotify_refresh_token', '')
session_bearer = session.get('access_token', '')
session_bearer = session.get('access_token', '') # This is the manually entered token or system token
token_source = session.get('token_source', '')
client_token_expiry = session.get('client_token_expiry', 0)
client_token_expiry = session.get('client_token_expiry', 0) # For system client_credentials token
# Use centralized token management to get the best available token
spotify_token, spotify_token_source = get_spotify_token()
# Fetch user info for the active token
spotify_user_info = None
spotify_user_info = None # This is passed to the template
active_username = None
active_user_id = None
active_user_image = None
active_token_expiry = None
# Check for an active token in the session
if session_bearer:
# If we have a valid token from centralized management, use it
if spotify_token and spotify_token_source in ['user', 'system']:
try:
# Set up the Spotify client with the token
sp = current_app.config['sp']
sp.set_auth(session_bearer)
spotify_user_info = spotify_helper_get_user_info(spotify_token)
if spotify_user_info:
token_source = spotify_token_source
session['token_source'] = spotify_token_source
active_user_id = spotify_user_info.get('id')
active_username = spotify_user_info.get('display_name') or active_user_id
images = spotify_user_info.get('images', [])
if images:
active_user_image = images[0].get('url')
# Set token expiry based on source
if spotify_token_source == 'user':
active_token_expiry = current_user.spotify_token_expiry
else: # system token
system_token_expiry_str = SystemSetting.get('system_spotify_token_expiry', '')
if system_token_expiry_str:
try:
active_token_expiry = datetime.fromisoformat(system_token_expiry_str)
except ValueError:
pass
current_app.logger.debug(f"Fetched Spotify user info for {active_username} using {spotify_token_source} token.")
except Exception as e:
current_app.logger.error(f"Error fetching Spotify user info with {spotify_token_source} token: {str(e)}")
if spotify_token_source == 'user':
flash("Your Spotify connection may have expired. Please try re-linking.", "warning")
# Priority 2: Manually entered bearer token from session (fallback)
if not spotify_user_info and session_bearer: # if no valid token from centralized management, check manual token
current_app.logger.debug(f"Attempting to use session_bearer token. Source: {token_source}")
# Client credentials don't have user context
if token_source != 'client_credentials':
try:
# Use the token to get user info
spotify_user_info = sp.current_user()
if spotify_user_info:
# If token_source indicates it's a user-like token or generic 'manual'
if token_source in ['manual', 'user_manual', 'user']: # 'user' if somehow set without db token
try:
resp = oauth.spotify.get('https://api.spotify.com/v1/me', token={'access_token': session_bearer, 'token_type': 'Bearer'})
if resp.ok:
spotify_user_info = resp.json()
if spotify_user_info and 'id' in spotify_user_info:
active_user_id = spotify_user_info.get('id')
active_username = spotify_user_info.get('display_name') or active_user_id
current_app.logger.debug(f"Found Spotify user: {active_username} (ID: {active_user_id})")
# Get profile image if available
images = spotify_user_info.get('images', [])
if images and len(images) > 0:
active_user_image = images[0].get('url')
except Exception as user_info_error:
current_app.logger.error(f"Error fetching Spotify user info: {str(user_info_error)}")
# For manual bearer tokens, try to determine expiry time
if token_source == '' or token_source not in ['user', 'client_credentials', 'system']:
# This is likely a manual bearer token
# Most bearer tokens are valid for 1 hour from issue
# We don't know when it was issued, but we can notify the user
# that these tokens typically expire after 1 hour
from datetime import timedelta
# Manual tokens stored in session likely were just added
token_added_time = session.get('bearer_token_added', now.timestamp())
typical_expiry = datetime.fromtimestamp(token_added_time) + timedelta(hours=1)
active_token_expiry = typical_expiry
# Mark it as a manual token for clarity
token_source = 'manual'
session['token_source'] = 'manual'
except Exception as e:
current_app.logger.error(f"Error setting up Spotify client: {e}")
# Determine Spotify connection status with corrected priority order
spotify_status = 'none' # Default: no connection
# Check for manually set bearer token (highest priority)
has_manual_bearer = 'access_token' in session and token_source == 'manual'
if has_manual_bearer:
spotify_status = 'bearer'
# Check user's own Spotify connection (second priority)
elif token_source == 'user' or (current_user.spotify_token and current_user.spotify_refresh_token):
if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now:
# User has valid token
spotify_status = 'user'
elif check_spotify_token(current_user):
# Token was refreshed successfully
spotify_status = 'user'
# Check for client credentials token (third priority)
elif token_source == 'client_credentials':
if images: active_user_image = images[0].get('url')
current_app.logger.debug(f"Fetched Spotify user info for {active_username} using manual bearer token.")
# Determine expiry for manual token (typically 1 hour from when it was added)
token_added_time = session.get('bearer_token_added', now.timestamp())
active_token_expiry = datetime.fromtimestamp(token_added_time) + timedelta(hours=1)
session['token_source'] = 'user_manual' # Clarify token source
token_source = 'user_manual'
else: # Token worked but no user ID, or not OK
current_app.logger.warning(f"Manual bearer token ({token_source}) did not return valid user info. Status: {resp.status_code}")
spotify_user_info = None # Ensure it's None
else:
current_app.logger.error(f"Error fetching Spotify user info with manual bearer token: {resp.status_code} {resp.text}")
spotify_user_info = None
if resp.status_code in [401, 403]: flash("Manually entered Spotify token is invalid or expired.", "warning")
except Exception as user_info_error:
current_app.logger.error(f"Exception fetching Spotify user info with manual bearer token: {str(user_info_error)}")
spotify_user_info = None
# If token_source indicates it's client_credentials or if user fetch failed, it might be client_credentials
if not spotify_user_info and token_source in ['client_credentials', 'client_credentials_manual']:
try:
resp_cc = oauth.spotify.get('https://api.spotify.com/v1/browse/new-releases', params={'limit':1}, token={'access_token': session_bearer, 'token_type': 'Bearer'})
if resp_cc.ok:
current_app.logger.info("Manual/Session token confirmed as working client credentials.")
if token_source == 'client_credentials_manual':
token_added_time = session.get('bearer_token_added', now.timestamp())
active_token_expiry = datetime.fromtimestamp(token_added_time) + timedelta(hours=1)
else: # system client_credentials
active_token_expiry = datetime.fromtimestamp(client_token_expiry) if client_token_expiry else None
else:
current_app.logger.warning(f"Manual/Session client credentials token validation failed. Status: {resp_cc.status_code}")
if resp_cc.status_code in [401, 403]: flash("The client credentials token in session is invalid.", "warning")
except Exception as cc_error:
current_app.logger.error(f"Exception validating client credentials token from session: {str(cc_error)}")
# Determine Spotify connection status based on the findings
spotify_status = 'none'
if spotify_token_source == 'user':
spotify_status = 'user'
elif spotify_token_source == 'system':
spotify_status = 'system'
elif token_source == 'user_manual' and spotify_user_info: # Successfully used manual token as user
spotify_status = 'bearer'
elif token_source in ['client_credentials', 'client_credentials_manual'] and session_bearer:
spotify_status = 'client_credentials'
return render_template(
@@ -688,112 +755,60 @@ The Quizzical Beats Team
@users_bp.route('/spotify-link', methods=['GET', 'POST'])
@login_required
def spotify_link():
"""Manage Spotify account connection"""
now = datetime.now()
"""
GET: Show management UI (manage_spotify.html) with current status and options.
POST: Trigger Spotify OAuth flow for linking/re-linking.
"""
if request.method == 'POST':
action = request.form.get('action')
if action == 'disconnect':
# Disconnect Spotify account
current_user.spotify_token = None
current_user.spotify_refresh_token = None
current_user.spotify_token_expiry = None
current_user.oauth_id = None
try:
db.session.commit()
flash('Your Spotify account has been disconnected', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error disconnecting Spotify: {e}")
flash('An error occurred while disconnecting your Spotify account', 'danger')
# Only POST triggers the OAuth flow
if not current_app.config.get('SPOTIFY_CLIENT_ID'):
flash('Spotify integration is not configured.', 'danger')
return redirect(url_for('users.spotify_link')) # Redirect to spotify_link page
redirect_uri = url_for('users.spotify_link_callback', _external=True)
return oauth.spotify.authorize_redirect(redirect_uri, show_dialog='true')
# GET: Show management UI
spotify_user_details = None
if current_user.spotify_id:
spotify_user_details = {
"id": current_user.spotify_id,
"display_name": session.get('spotify_display_name', current_user.spotify_id)
}
return render_template('users/spotify_link.html', now=now)
now = datetime.now()
spotify_user_info = session.get('spotify_user_info')
return render_template('users/manage_spotify.html',
spotify_user_details=spotify_user_details,
now=now,
spotify_user_info=spotify_user_info)
@users_bp.route('/spotify-auth')
@users_bp.route('/spotify-link/callback')
@login_required
def spotify_auth():
"""Initiate Spotify OAuth flow"""
def spotify_link_callback():
"""Callback for linking Spotify to an existing user account."""
try:
sp_oauth = SpotifyOAuth(
client_id=current_app.config['SPOTIFY_CLIENT_ID'],
client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=current_app.config['SPOTIFY_SCOPE']
)
auth_url = sp_oauth.get_authorize_url()
token = oauth.spotify.authorize_access_token()
user_info = get_spotify_user_info(token)
# Store state in session for validation
session['oauth_state'] = sp_oauth.state
return redirect(auth_url)
except Exception as e:
current_app.logger.error(f"Error initiating Spotify auth: {e}")
flash('Error connecting to Spotify. Please try again.', 'danger')
return redirect(url_for('users.spotify_link'))
if not user_info or 'id' not in user_info:
flash('Failed to get user information from Spotify.', 'danger')
current_app.logger.error(f"Spotify link callback: Missing ID in user_info for user {current_user.id}. Info: {user_info}")
return redirect(url_for('users.spotify_link')) # Redirect to spotify_link page
_process_spotify_link(current_user, token, user_info)
@users_bp.route('/spotify-callback')
@login_required
def spotify_callback():
"""Handle Spotify OAuth callback"""
try:
# Verify state parameter
if request.args.get('state') != session.get('oauth_state'):
flash('Authentication state mismatch. Please try again.', 'danger')
return redirect(url_for('users.spotify_link'))
# Get authorization code
code = request.args.get('code')
if not code:
flash('No authorization code received from Spotify.', 'danger')
return redirect(url_for('users.spotify_link'))
# Exchange code for token
sp_oauth = SpotifyOAuth(
client_id=current_app.config['SPOTIFY_CLIENT_ID'],
client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=current_app.config['SPOTIFY_SCOPE']
)
token_info = sp_oauth.get_access_token(code)
if not token_info or 'access_token' not in token_info:
flash('Failed to obtain access token from Spotify.', 'danger')
return redirect(url_for('users.spotify_link'))
# Save token to user
current_user.spotify_token = token_info['access_token']
current_user.spotify_refresh_token = token_info.get('refresh_token')
expiry = datetime.fromtimestamp(token_info['expires_at']) if 'expires_at' in token_info else None
current_user.spotify_token_expiry = expiry
# Get Spotify user ID
try:
sp = current_app.config['sp']
sp.set_auth(token_info['access_token'])
user_info = sp.current_user()
current_user.oauth_id = user_info['id']
except:
# Continue even if we can't get the Spotify ID
current_app.logger.warning("Could not fetch Spotify user ID")
# Save to database
try:
db.session.commit()
flash('Successfully connected to Spotify!', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error saving Spotify token: {e}")
flash('Error saving Spotify connection.', 'danger')
return redirect(url_for('users.spotify_link'))
except Exception as e:
current_app.logger.error(f"Error during Spotify callback: {e}")
flash('Error during Spotify authentication. Please try again.', 'danger')
return redirect(url_for('users.spotify_link'))
current_app.logger.error(f"Error in Spotify link callback for user {current_user.id}: {str(e)}")
flash('An error occurred while linking your Spotify account. Please try again.', 'danger')
return redirect(url_for('users.spotify_link')) # Redirect to spotify_link page
@users_bp.route('/spotify/disconnect', methods=['POST'])
@login_required
def spotify_disconnect():
"""Disconnect user's Spotify account."""
_process_spotify_disconnect(current_user)
return redirect(url_for('users.spotify_link')) # Redirect to spotify_link page
@users_bp.route('/update-bearer-token', methods=['POST'])
@login_required
@@ -804,6 +819,7 @@ def update_bearer_token():
session.pop('access_token', None)
session.pop('token_source', None)
session.pop('bearer_token_added', None)
current_app.logger.info(f"User {current_user.id} cleared Spotify bearer token from session")
flash('Spotify bearer token has been cleared', 'success')
return redirect(url_for('users.profile'))
@@ -814,117 +830,58 @@ def update_bearer_token():
return redirect(url_for('users.profile'))
try:
# Store the token in session with timestamp and mark as manual
# Store the token in session with timestamp and mark as manual initially
session['access_token'] = bearer_token
session['token_source'] = 'manual'
session['token_source'] = 'manual' # Initial assumption
session['bearer_token_added'] = datetime.now().timestamp()
# Test the token with a simple request to validate it
sp = current_app.config['sp']
sp.set_auth(bearer_token)
current_app.logger.info(f"User {current_user.id} added a manual bearer token to session. Validating: {bearer_token[:10]}...")
# Try to get current user info as a test
user_info = sp.current_user()
if user_info and 'id' in user_info:
username = user_info.get('display_name') or user_info.get('id')
flash(f'Successfully authenticated with Spotify as {username}', 'success')
# Log who this token belongs to
current_app.logger.info(f"Manual bearer token added for Spotify user: {username} (ID: {user_info.get('id')})")
else:
flash('Token saved but validation failed. The token may be invalid or expired.', 'warning')
except Exception as e:
current_app.logger.error(f"Error validating bearer token: {e}")
flash(f'Token saved but error during validation: {str(e)}', 'warning')
try:
resp_me = oauth.spotify.get('https://api.spotify.com/v1/me', token={'access_token': bearer_token, 'token_type': 'Bearer'})
user_info = resp_me.json() if resp_me.ok else None
if user_info and 'id' in user_info:
# This is a user OAuth token
username = user_info.get('display_name') or user_info.get('id')
# Update token source to reflect it's a user token
session['token_source'] = 'user_manual'
current_app.logger.info(f"Token identified as user OAuth token for: {username} (ID: {user_info.get('id')})")
flash(f'Successfully authenticated with Spotify as {username}', 'success')
current_app.logger.info(f"Manual bearer token added for Spotify user: {username} (ID: {user_info.get('id')})")
else:
current_app.logger.warning(f"Manual token is not a valid user token. Response: {resp_me.status_code if not resp_me.ok else 'OK, but no ID or user_info was None'}")
raise Exception("Not a user token or failed to fetch user info")
except Exception as user_error:
current_app.logger.warning(f"Validating as user token failed: {str(user_error)}. Checking if client credentials token...")
try:
resp_browse = oauth.spotify.get('https://api.spotify.com/v1/browse/new-releases', params={'limit':1}, token={'access_token': bearer_token, 'token_type': 'Bearer'})
browse_results = resp_browse.json() if resp_browse.ok else None
if browse_results and 'albums' in browse_results:
# This looks like a client credentials token
session['token_source'] = 'client_credentials_manual'
current_app.logger.info("Token identified as client credentials token (manual)")
flash('Token saved as client credentials token. This type of token cannot access user-specific data.', 'warning')
else:
flash('Token saved but validation failed (cannot fetch new releases). The token may be invalid or expired.', 'warning')
current_app.logger.warning(f"Manual token validation as client_credentials failed. Response: {resp_browse.status_code if not resp_browse.ok else 'OK, but no albums or browse_results was None'}")
except Exception as e_browse:
current_app.logger.error(f"Error validating bearer token as client credentials: {e_browse}")
flash(f'Token saved but error during client credentials validation: {str(e_browse)}', 'warning')
except Exception as e_main:
current_app.logger.error(f"Error processing bearer token: {e_main}")
flash(f'Error processing token: {str(e_main)}', 'warning')
return redirect(url_for('users.profile'))
@users_bp.route('/use-refresh-token', methods=['POST'])
@login_required
def use_refresh_token():
"""Generate a new access token using the stored refresh token"""
# Check if user has a refresh token
if not current_user.spotify_refresh_token:
flash('No Spotify refresh token found. Please connect your Spotify account first.', 'warning')
return redirect(url_for('users.profile'))
try:
# Create OAuth object
sp_oauth = SpotifyOAuth(
client_id=current_app.config['SPOTIFY_CLIENT_ID'],
client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=current_app.config['SPOTIFY_SCOPE']
)
# Refresh the token
token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token)
if not token_info or 'access_token' not in token_info:
flash('Failed to refresh access token from Spotify.', 'danger')
return redirect(url_for('users.profile'))
# Update user model with new token information
current_user.spotify_token = token_info['access_token']
current_user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at']) if 'expires_at' in token_info else None
# If we got a new refresh token (unusual but possible), store it
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
# Save to database
db.session.commit()
# Also set token in the session for direct API access
session['access_token'] = token_info['access_token']
# Validate the token by getting user info
sp = current_app.config['sp']
sp.set_auth(token_info['access_token'])
user_info = sp.current_user()
if user_info and 'id' in user_info:
flash(f'Successfully generated new token for {user_info.get("display_name", user_info["id"])}', 'success')
else:
flash('Token generated but validation failed.', 'warning')
except Exception as e:
current_app.logger.error(f"Error refreshing token: {e}")
flash(f'Error refreshing token: {str(e)}', 'danger')
return redirect(url_for('users.profile'))
def check_spotify_token(user):
"""
Helper function to check if user's Spotify token needs to be refreshed
Returns True if token is valid, False if not
"""
if not user.spotify_token or not user.spotify_refresh_token or not user.spotify_token_expiry:
return False
now = datetime.now()
# If token expires in less than 5 minutes, refresh it
if user.spotify_token_expiry - now < timedelta(minutes=5):
sp_oauth = SpotifyOAuth(
client_id=current_app.config['SPOTIFY_CLIENT_ID'],
client_secret=current_app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=current_app.config['SPOTIFY_SCOPE']
)
try:
token_info = sp_oauth.refresh_access_token(user.spotify_refresh_token)
user.spotify_token = token_info['access_token']
user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at'])
db.session.commit()
return True
except Exception as e:
current_app.logger.error(f"Error refreshing Spotify token: {e}")
return False
return True
# This will be reviewed and updated for authlib
pass
@users_bp.route('/setup')
@login_required