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
+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)