Add Spotify integration with improved token management and user interface

- Implemented Spotify OAuth handling in spotify_client_manager.py to ensure valid access tokens for users.
- Created helper functions in spotify_helper.py for refreshing tokens and retrieving user information.
- Developed manage_spotify.html template for connecting and managing Spotify accounts, displaying connection status and token information.
- Added logging for token management processes to enhance debugging and monitoring.
- Introduced a debug client for Spotify interactions to facilitate easier testing and development.
This commit is contained in:
Christian Krakau-Louis
2025-05-27 21:40:37 +02:00
parent d2772b88fe
commit 2f55f898ed
44 changed files with 2317 additions and 1884 deletions
+125 -140
View File
@@ -1,6 +1,7 @@
import os
import logging
import importlib.util
import json
from flask import Flask, session, redirect, url_for, request
from flask_login import LoginManager, current_user
from flask_sqlalchemy import SQLAlchemy
@@ -8,10 +9,10 @@ from flask_wtf.csrf import CSRFProtect
from dotenv import load_dotenv
from werkzeug.middleware.proxy_fix import ProxyFix
from importlib import import_module
import spotipy
from spotipy.oauth2 import SpotifyOAuth
from musicround.config import Config
from musicround.version import VERSION_INFO, get_version_str
from datetime import datetime
from musicround.helpers.auth_helpers import oauth # Import the oauth object
# Initialize SQLAlchemy
db = SQLAlchemy()
@@ -177,148 +178,132 @@ def create_app(config=None):
except OSError:
pass
# Initialize OAuth providers (Google, Authentik)
# Define token handling functions within create_app
def _app_fetch_token(name):
app.logger.debug(f"_app_fetch_token: Called for service '{name}', user: {current_user.id if current_user.is_authenticated else 'Unauthenticated'}")
if current_user.is_authenticated:
if name == 'spotify':
token_str = current_user.spotify_token
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Raw token string from DB: {token_str[:150] if token_str else 'None'}...")
if token_str:
try:
token = json.loads(token_str)
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Token after json.loads: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'expires_in': {token.get('expires_in')}, 'scope': {token.get('scope')}, 'token_type': '{token.get('token_type')}'}}")
if 'refresh_token' not in token or not token.get('refresh_token'):
if hasattr(current_user, 'spotify_refresh_token') and current_user.spotify_refresh_token:
token['refresh_token'] = current_user.spotify_refresh_token
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Added refresh_token from current_user.spotify_refresh_token.")
else:
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): refresh_token missing in JSON and not found in current_user.spotify_refresh_token.")
current_time = int(datetime.utcnow().timestamp())
if 'expires_at' in token:
if not isinstance(token['expires_at'], int):
try:
token['expires_at'] = int(float(token['expires_at']))
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Converted existing expires_at to int: {token['expires_at']}")
except (ValueError, TypeError):
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): Could not convert existing expires_at '{token['expires_at']}' to int. Recalculating if possible.")
if 'expires_in' in token and isinstance(token['expires_in'], (int, float)):
token['expires_at'] = current_time + int(token['expires_in']) - 30
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Recalculated expires_at from expires_in: {token['expires_at']}")
else:
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Cannot determine expires_at. Original problematic value: {token['expires_at']}")
elif 'expires_in' in token and isinstance(token['expires_in'], (int, float)):
token['expires_at'] = current_time + int(token['expires_in']) - 30
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Calculated expires_at from expires_in: {token['expires_at']}")
elif hasattr(current_user, 'spotify_token_expires_at') and current_user.spotify_token_expires_at:
token['expires_at'] = int(current_user.spotify_token_expires_at.timestamp())
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Used expires_at from current_user.spotify_token_expires_at: {token['expires_at']}")
else:
app.logger.warning(f"_app_fetch_token for Spotify (user {current_user.id}): expires_at missing and cannot be calculated.")
if 'token_type' not in token or not token.get('token_type'):
token['token_type'] = 'Bearer'
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Set token_type to Bearer.")
if 'expires_in' in token:
del token['expires_in']
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): Final token prepared for Authlib: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'token_type': '{token.get('token_type')}', 'scope': {token.get('scope')}}}")
return token
except json.JSONDecodeError:
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Failed to decode token JSON: {token_str[:100]}...")
return None
except Exception as e:
app.logger.error(f"_app_fetch_token for Spotify (user {current_user.id}): Error processing token: {str(e)}", exc_info=True)
return None
else:
app.logger.debug(f"_app_fetch_token for Spotify (user {current_user.id}): No token string found in DB.")
return None
app.logger.debug(f"_app_fetch_token: User not authenticated or service not matched for '{name}'.")
return None
def _app_update_token(name, token, refresh_token=None, access_token=None):
app.logger.debug(f"_app_update_token: Called for service: {name}, user: {current_user.id if current_user.is_authenticated else 'Unauthenticated'}")
if name == 'spotify':
if current_user.is_authenticated:
app.logger.info(f"_app_update_token for Spotify (user {current_user.id}): Received new token data to update. Keys: {list(token.keys()) if token else 'None'}")
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Full new token: {{'access_token': 'ACCESS_TOKEN_REDACTED', 'refresh_token': '{'REFRESH_TOKEN_REDACTED' if token.get('refresh_token') else 'None'}', 'expires_at': {token.get('expires_at')}, 'token_type': '{token.get('token_type')}', 'scope': {token.get('scope')}}}")
current_user.spotify_token = json.dumps(token)
if 'expires_at' in token and token['expires_at'] is not None and hasattr(current_user, 'spotify_token_expires_at'):
try:
current_user.spotify_token_expires_at = datetime.fromtimestamp(int(token['expires_at']))
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Updated spotify_token_expires_at to {current_user.spotify_token_expires_at}")
except (TypeError, ValueError) as e:
app.logger.warning(f"_app_update_token for Spotify (user {current_user.id}): Could not update spotify_token_expires_at from token's expires_at ('{token['expires_at']}'): {str(e)}")
if 'refresh_token' in token and token['refresh_token'] and hasattr(current_user, 'spotify_refresh_token'):
current_user.spotify_refresh_token = token['refresh_token']
app.logger.debug(f"_app_update_token for Spotify (user {current_user.id}): Updated spotify_refresh_token.")
try:
db.session.commit()
app.logger.info(f"_app_update_token for Spotify (user {current_user.id}): Token successfully updated and committed to DB.")
except Exception as e:
db.session.rollback()
app.logger.error(f"_app_update_token for Spotify (user {current_user.id}): Error committing token to DB: {str(e)}", exc_info=True)
else:
app.logger.warning(f"_app_update_token for Spotify: Attempted to update token for unauthenticated user.")
# Add similar blocks for other services if needed
# Initialize OAuth providers (Google, Authentik, Spotify via Authlib)
from musicround.helpers.auth_helpers import init_oauth
init_oauth(app)
# Initialize Spotify client for common API access
# This will be available for any authenticated route
if app.config['SPOTIFY_CLIENT_ID'] and app.config['SPOTIFY_CLIENT_SECRET']:
app.config['sp_oauth'] = SpotifyOAuth(
client_id=app.config['SPOTIFY_CLIENT_ID'],
client_secret=app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=app.config['SPOTIFY_REDIRECT_URI'],
scope=app.config['SPOTIFY_SCOPE']
)
# Create a Spotify client that will be used throughout the app
app.config['sp'] = spotipy.Spotify(auth_manager=app.config['sp_oauth'])
# Initialize Deezer client - import inside the function to avoid circular dependency
init_oauth(app) # This will use the imported oauth object
# Manually register token handling functions
app.logger.info(f"Attempting to manually register token functions. oauth object id: {id(oauth)}")
if hasattr(oauth, 'tokengetter') and callable(oauth.tokengetter):
oauth.tokengetter(_app_fetch_token)
app.logger.info("SUCCESS: Manually registered _app_fetch_token using oauth.tokengetter().")
else:
app.logger.error("FAILURE: oauth.tokengetter method not found or not callable.")
# Fallback for extreme cases - not recommended for production
if isinstance(oauth, object) and hasattr(oauth, '_fetch_token_funcs') and isinstance(oauth._fetch_token_funcs, dict): # Basic check
oauth._fetch_token_funcs['_app_fetch_token'] = _app_fetch_token
app.logger.warning("MANUAL HACK: Injected _app_fetch_token into oauth._fetch_token_funcs.")
else:
app.logger.error("CRITICAL FAILURE: Cannot register fetch token function via method or hack.")
if hasattr(oauth, 'tokenupdater') and callable(oauth.tokenupdater):
oauth.tokenupdater(_app_update_token)
app.logger.info("SUCCESS: Manually registered _app_update_token using oauth.tokenupdater().")
else:
app.logger.error("FAILURE: oauth.tokenupdater method not found or not callable.")
if isinstance(oauth, object) and hasattr(oauth, '_update_token_funcs') and isinstance(oauth._update_token_funcs, dict): # Basic check
oauth._update_token_funcs['_app_update_token'] = _app_update_token
app.logger.warning("MANUAL HACK: Injected _app_update_token into oauth._update_token_funcs.")
else:
app.logger.error("CRITICAL FAILURE: Cannot register update token function via method or hack.")
# Initialize Deezer client - import inside the function to avoid circular dependency
from musicround.deezer_client import DeezerClient
app.config['deezer'] = DeezerClient()
# Add before_request handler to ensure Spotify token is available
@app.before_request
def ensure_spotify_token():
"""
Ensure a valid Spotify token is available in the session.
Priority:
1. Use existing manual bearer token if present in session
2. Try to refresh user's token if they have a refresh token
3. Use client credentials flow as fallback (no user login required)
"""
# Skip for static files and certain paths
if request.path.startswith('/static') or request.path.startswith('/favicon.ico'):
return
# If we already have a manual token in session, don't do anything
# Manual tokens take priority over everything else
if 'access_token' in session and session.get('token_source') != 'user' and session.get('token_source') != 'client_credentials':
app.logger.debug("Using existing manual bearer token")
return
from datetime import datetime
from spotipy.oauth2 import SpotifyOAuth
from .models import SystemSetting
import base64
import requests
try:
# Only check user token if user is logged in
if current_user.is_authenticated:
# Step 1: Try to use user's refresh token
if current_user.spotify_refresh_token:
app.logger.debug(f"Attempting to refresh token for user {current_user.username}")
# Create OAuth manager for token refresh
sp_oauth = SpotifyOAuth(
client_id=app.config['SPOTIFY_CLIENT_ID'],
client_secret=app.config['SPOTIFY_CLIENT_SECRET'],
redirect_uri=url_for('users.spotify_callback', _external=True),
scope=app.config['SPOTIFY_SCOPE']
)
try:
# Refresh user's token
token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token)
if token_info and 'access_token' in token_info:
# Update user's tokens in database
current_user.spotify_token = token_info['access_token']
current_user.spotify_token_expiry = datetime.fromtimestamp(token_info['expires_at'])
# If we got a new refresh token (rare but possible), update it
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
# Save to database
db.session.commit()
# Store token in session
session['access_token'] = token_info['access_token']
session['token_source'] = 'user'
app.logger.debug(f"Generated new token for user {current_user.username}")
return
except Exception as e:
app.logger.warning(f"Failed to refresh user token: {str(e)}")
# Step 2: If no user token or user not logged in, use client credentials flow
# Check if we already have a valid client credentials token
client_token_expiry = session.get('client_token_expiry', 0)
if 'access_token' in session and session.get('token_source') == 'client_credentials' and client_token_expiry > datetime.now().timestamp():
app.logger.debug("Using existing client credentials token")
return
# Get client credentials from config
client_id = app.config['SPOTIFY_CLIENT_ID']
client_secret = app.config['SPOTIFY_CLIENT_SECRET']
if client_id and client_secret:
app.logger.debug("Getting new token via client credentials flow")
# Encode client credentials
auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
# Prepare headers and payload
headers = {
'Authorization': f'Basic {auth_header}',
'Content-Type': 'application/x-www-form-urlencoded'
}
data = {
'grant_type': 'client_credentials'
}
try:
# Make the POST request
response = requests.post('https://accounts.spotify.com/api/token', headers=headers, data=data)
response.raise_for_status()
token_data = response.json()
if 'access_token' in token_data:
# Store the token in session
session['access_token'] = token_data['access_token']
session['token_source'] = 'client_credentials'
# Calculate and store expiry time (typically 1 hour from now)
expires_in = token_data.get('expires_in', 3600) # Default to 1 hour
expiry_timestamp = datetime.now().timestamp() + expires_in
session['client_token_expiry'] = expiry_timestamp
app.logger.debug("Successfully obtained client credentials token")
return
else:
app.logger.warning("No access token in client credentials response")
except Exception as e:
app.logger.error(f"Error getting client credentials token: {str(e)}")
except Exception as e:
app.logger.error(f"Error in ensure_spotify_token: {str(e)}")
pass # Continue without a token if all methods fail
# Register blueprints
from musicround.routes.core import core_bp
from musicround.routes.users import users_bp
+2 -2
View File
@@ -34,11 +34,11 @@ class Config:
SQLALCHEMY_TRACK_MODIFICATIONS = False
# Spotify API credentials
# Spotify API credentials
SPOTIFY_CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID")
SPOTIFY_CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET")
SPOTIFY_REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI")
SPOTIFY_SCOPE = "playlist-read-private playlist-read-collaborative user-library-read user-top-read"
SPOTIFY_SCOPE = "playlist-read-private playlist-read-collaborative user-library-read user-top-read user-read-private user-read-email user-read-recently-played user-follow-read playlist-modify-public playlist-modify-private"
# Deezer API credentials
DEEZER_APP_ID = os.getenv("DEEZER_APP_ID", "")
+79 -8
View File
@@ -5,6 +5,7 @@ import random
import time
from flask import current_app
from musicround.models import Song, db
from musicround.helpers.metadata import get_song_metadata_by_isrc
logger = logging.getLogger(__name__)
@@ -161,11 +162,55 @@ class DeezerClient:
if not preview_url:
self.logger.warning(f"Track {track_info.get('title')} has no preview URL")
return None
# Extract ISRC
isrc = track_info.get('isrc')
# Check if this track is already in our database
existing_song = Song.query.filter_by(deezer_id=str(track_info['id'])).first()
# Check if this track is already in our database by Deezer ID or ISRC
existing_song = None
if isrc:
existing_song = Song.query.filter_by(isrc=isrc).first()
if not existing_song:
existing_song = Song.query.filter_by(deezer_id=str(track_info['id'])).first()
if existing_song:
self.logger.info(f"Track {track_info.get('title')} already exists in database")
self.logger.info(f"Track {track_info.get('title')} (Deezer ID: {track_info['id']}, ISRC: {isrc}) already exists in database with ID {existing_song.id}")
# If ISRC was missing and we found it now, update the existing record
if isrc and not existing_song.isrc:
existing_song.isrc = isrc
if existing_song.deezer_id is None: # If it was matched by ISRC but didn't have deezer_id
existing_song.deezer_id = str(track_info['id'])
try:
db.session.commit()
self.logger.info(f"Updated ISRC for existing song {existing_song.id} to {isrc}")
except Exception as e:
db.session.rollback()
self.logger.error(f"Error updating ISRC for existing song {existing_song.id}: {e}")
# Optionally, trigger metadata refresh if ISRC is now available or if desired
if existing_song.isrc:
try:
app_context = current_app._get_current_object()
updated_metadata = get_song_metadata_by_isrc(existing_song.isrc, app=app_context)
if updated_metadata:
# Update song fields from aggregated metadata
existing_song.title = updated_metadata.get('title', existing_song.title)
existing_song.artist = updated_metadata.get('artist_name', existing_song.artist)
existing_song.year = updated_metadata.get('year', existing_song.year)
existing_song.genre = updated_metadata.get('genre', existing_song.genre)
# ... update other relevant fields ...
if updated_metadata.get('spotify_id') and not existing_song.spotify_id:
existing_song.spotify_id = updated_metadata.get('spotify_id')
if updated_metadata.get('cover_url') and not existing_song.cover_url: # Prioritize existing cover if any
existing_song.cover_url = updated_metadata.get('cover_url')
if updated_metadata.get('preview_url') and not existing_song.preview_url: # Prioritize existing preview if any
existing_song.preview_url = updated_metadata.get('preview_url')
db.session.commit()
self.logger.info(f"Refreshed metadata for existing song {existing_song.id} using ISRC {existing_song.isrc}")
except Exception as e:
db.session.rollback()
self.logger.error(f"Error refreshing metadata for existing song {existing_song.id}: {e}")
return existing_song
# Get additional artist details if needed
@@ -184,27 +229,53 @@ class DeezerClient:
# Get highest quality cover image
cover_url = album_info.get('cover_xl') or album_info.get('cover_big') or album_info.get('cover_medium', '')
# Get genre from Last.fm
genre = self.get_genre_from_lastfm(artist_name, track_info.get('title', ''), lastfm_api_key)
# Get genre from Last.fm (can be removed if metadata aggregation handles it)
# genre = self.get_genre_from_lastfm(artist_name, track_info.get('title', ''), lastfm_api_key)
genre = None # Will be populated by metadata aggregation if ISRC is present
# Create new Song object
new_song = Song(
deezer_id=str(track_info['id']),
spotify_id=None, # We don't have Spotify ID for Deezer tracks
spotify_id=None, # Will be populated by metadata aggregation if ISRC is present
title=track_info.get('title', ''),
artist=artist_name,
genre=genre,
year=release_year,
preview_url=preview_url,
cover_url=cover_url,
popularity=track_info.get('rank', 0),
popularity=track_info.get('rank', 0), # Deezer 'rank' can be used as popularity
isrc=isrc, # Save the ISRC
used_count=0
)
try:
db.session.add(new_song)
db.session.commit()
self.logger.info(f"Imported track '{new_song.title}' by {new_song.artist}")
self.logger.info(f"Imported track '{new_song.title}' by {new_song.artist} with Deezer ID {new_song.deezer_id} and ISRC {new_song.isrc}")
# If ISRC is present, fetch and update with aggregated metadata
if new_song.isrc:
try:
app_context = current_app._get_current_object()
aggregated_metadata = get_song_metadata_by_isrc(new_song.isrc, app=app_context)
if aggregated_metadata:
new_song.title = aggregated_metadata.get('title', new_song.title)
new_song.artist = aggregated_metadata.get('artist_name', new_song.artist)
new_song.year = aggregated_metadata.get('year', new_song.year)
new_song.genre = aggregated_metadata.get('genre', new_song.genre)
new_song.spotify_id = aggregated_metadata.get('spotify_id', new_song.spotify_id)
# Update cover and preview URLs if they are better or missing
if aggregated_metadata.get('cover_url'):
new_song.cover_url = aggregated_metadata.get('cover_url')
if aggregated_metadata.get('preview_url'):
new_song.preview_url = aggregated_metadata.get('preview_url')
# Potentially update popularity if a more universal score is available
# new_song.popularity = aggregated_metadata.get('popularity', new_song.popularity)
db.session.commit()
self.logger.info(f"Updated new song {new_song.id} with aggregated metadata using ISRC {new_song.isrc}")
except Exception as e:
db.session.rollback()
self.logger.error(f"Error updating new song {new_song.id} with aggregated metadata: {e}")
return new_song
except Exception as e:
db.session.rollback()
+70 -3
View File
@@ -4,13 +4,10 @@ Authentication helper functions for OAuth providers
import os
from flask import current_app, url_for, session, flash, redirect, request
from authlib.integrations.flask_client import OAuth
from flask_login import login_user, current_user
from functools import wraps
from datetime import datetime, timedelta
import requests
from musicround.models import db, User
# Initialize OAuth object
oauth = OAuth()
@@ -68,6 +65,26 @@ def init_oauth(app):
app.logger.info("Dropbox OAuth client registered")
else:
app.logger.warning("Dropbox OAuth client not registered - missing app key or secret")
# Register Spotify OAuth client
if app.config.get('SPOTIFY_CLIENT_ID') and app.config.get('SPOTIFY_CLIENT_SECRET'):
oauth.register(
name='spotify',
client_id=app.config.get('SPOTIFY_CLIENT_ID'),
client_secret=app.config.get('SPOTIFY_CLIENT_SECRET'),
api_base_url='https://api.spotify.com/v1/',
authorize_url='https://accounts.spotify.com/authorize',
authorize_params={'show_dialog': 'true'}, # Force re-approval
access_token_url='https://accounts.spotify.com/api/token',
access_token_params=None,
refresh_token_url='https://accounts.spotify.com/api/token',
client_kwargs={
'scope': app.config.get('SPOTIFY_SCOPE')
},
userinfo_endpoint='https://api.spotify.com/v1/me' # Added for fetching user info
)
app.logger.info("Spotify OAuth client registered")
else:
app.logger.warning("Spotify OAuth client not registered - missing client ID or secret")
return oauth
@@ -171,10 +188,48 @@ def get_dropbox_user_info(token):
current_app.logger.error(f"Error getting Dropbox user info: {str(e)}")
return None
def get_spotify_user_info(token):
"""
Get Spotify user info from the token
"""
try:
# Authlib should handle token refresh automatically if configured correctly
# and if the token object is managed by Authlib's token session or similar mechanism.
# Use the registered Authlib client to fetch user info
# The 'userinfo_endpoint' configured during registration will be used.
# We pass the token explicitly to ensure it's used for this request.
# Authlib's `oauth.spotify.get()` will prepend the base URL if 'userinfo_endpoint' is relative,
# but since we provided an absolute one, it should use that.
# The error "Invalid URL 'me'" suggests that 'me' alone was passed somewhere.
# Let's ensure we are calling the fully qualified endpoint via the client.
resp = oauth.spotify.get('https://api.spotify.com/v1/me', token=token)
resp.raise_for_status() # Raise an exception for HTTP errors (4xx or 5xx)
profile = resp.json()
current_app.logger.debug(f"Spotify user info response: {profile}")
user_info = {
'id': profile.get('id'),
'email': profile.get('email'), # Note: Spotify email might be private
'name': profile.get('display_name'),
'picture': profile.get('images')[0]['url'] if profile.get('images') else None,
# Spotify doesn't provide given_name and family_name directly
'given_name': profile.get('display_name', '').split(' ')[0] if profile.get('display_name') else '',
'family_name': ' '.join(profile.get('display_name', '').split(' ')[1:]) if profile.get('display_name') and ' ' in profile.get('display_name') else ''
}
return user_info
except requests.exceptions.HTTPError as http_err:
current_app.logger.error(f"HTTP error getting Spotify user info: {http_err} - Response: {http_err.response.text}")
return None
except Exception as e:
current_app.logger.error(f"Error getting Spotify user info: {str(e)}")
return None
def find_or_create_user(user_info, auth_provider):
"""
Find existing user or create a new one based on OAuth user info
"""
from musicround.models import db, User
if not user_info:
return None
@@ -185,6 +240,8 @@ def find_or_create_user(user_info, auth_provider):
user = User.query.filter_by(authentik_id=user_info['id']).first()
elif auth_provider == 'dropbox':
user = User.query.filter_by(dropbox_id=user_info['id']).first()
elif auth_provider == 'spotify':
user = User.query.filter_by(spotify_id=user_info['id']).first()
else:
return None
@@ -200,6 +257,8 @@ def find_or_create_user(user_info, auth_provider):
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
elif auth_provider == 'spotify':
user.spotify_id = user_info['id']
db.session.commit()
current_app.logger.info(f"Updated existing user {user.username} with {auth_provider} ID")
@@ -243,6 +302,8 @@ def find_or_create_user(user_info, auth_provider):
user.authentik_id = user_info['id']
elif auth_provider == 'dropbox':
user.dropbox_id = user_info['id']
elif auth_provider == 'spotify':
user.spotify_id = user_info['id']
db.session.add(user)
try:
@@ -259,6 +320,7 @@ def update_oauth_tokens(user, tokens, auth_provider):
"""
Update user's OAuth tokens
"""
from musicround.models import db
if auth_provider == 'google':
user.google_token = tokens.get('access_token')
user.google_refresh_token = tokens.get('refresh_token')
@@ -270,6 +332,11 @@ def update_oauth_tokens(user, tokens, auth_provider):
user.dropbox_refresh_token = tokens.get('refresh_token')
if tokens.get('expires_in'):
user.dropbox_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in')))
elif auth_provider == 'spotify':
user.spotify_token = tokens.get('access_token')
user.spotify_refresh_token = tokens.get('refresh_token')
if tokens.get('expires_in'):
user.spotify_token_expiry = datetime.now() + timedelta(seconds=int(tokens.get('expires_in')))
user.last_login = datetime.now()
try:
db.session.commit()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,83 @@
"""
Updated core.py with improved Spotify OAuth handling
"""
import logging
import datetime
from flask import current_app, redirect, url_for, flash, session
from flask_login import current_user
from musicround.helpers.spotify_debug import DebugSpotifyClient
from musicround.models import db
logger = logging.getLogger("spotify.token")
def ensure_valid_spotify_token(session, current_user):
"""
Ensure a valid Spotify token is available and return a Spotify client
Args:
session: The Flask session object
current_user: The current user object
Returns:
DebugSpotifyClient or None if no valid token
"""
# Get token from session
token = session.get('access_token')
# If no token in session but user is logged in with a token
if not token and hasattr(current_user, 'is_authenticated') and current_user.is_authenticated:
if hasattr(current_user, 'spotify_token') and current_user.spotify_token:
token = current_user.spotify_token
session['access_token'] = token
if not token:
logger.warning("No Spotify token available")
return None
# Create Spotify client
sp = DebugSpotifyClient(auth=token)
# Verify token is valid
try:
# Try a simple API call
sp.current_user()
return sp
except Exception as e:
logger.warning(f"Spotify token validation failed: {str(e)}")
# Try to refresh the token
if hasattr(current_user, 'is_authenticated') and current_user.is_authenticated and hasattr(current_user, 'spotify_refresh_token') and current_user.spotify_refresh_token:
try:
from spotipy.oauth2 import SpotifyOAuth
from musicround.config import Config
# Create OAuth object to refresh token
sp_oauth = SpotifyOAuth(
client_id=Config.SPOTIFY_CLIENT_ID,
client_secret=Config.SPOTIFY_CLIENT_SECRET,
redirect_uri=Config.SPOTIFY_REDIRECT_URI,
scope=Config.SPOTIFY_SCOPE
)
# Get new token
token_info = sp_oauth.refresh_access_token(current_user.spotify_refresh_token)
# Update session and user
session['access_token'] = token_info['access_token']
current_user.spotify_token = token_info['access_token']
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
# Update token expiry
current_user.spotify_token_expiry = datetime.datetime.now() + datetime.timedelta(seconds=token_info['expires_in'])
# Save changes
db.session.commit()
# Create new Spotify client with updated token
sp = DebugSpotifyClient(auth=token_info['access_token'])
return sp
except Exception as refresh_error:
logger.error(f"Error refreshing token: {str(refresh_error)}")
return None
View File
+193
View File
@@ -0,0 +1,193 @@
"""
Spotify token management helper functions
Provides centralized token refresh functionality similar to Dropbox helper
"""
import requests
import time
from datetime import datetime, timedelta
from flask import current_app
from flask_login import current_user
from musicround.models import db, SystemSetting
def refresh_spotify_token(refresh_token):
"""Refresh an expired Spotify access token"""
client_id = current_app.config.get('SPOTIFY_CLIENT_ID')
client_secret = current_app.config.get('SPOTIFY_CLIENT_SECRET')
if not client_id or not client_secret:
current_app.logger.error("Spotify client credentials not configured")
return None
data = {
'grant_type': 'refresh_token',
'refresh_token': refresh_token,
'client_id': client_id,
'client_secret': client_secret
}
response = requests.post('https://accounts.spotify.com/api/token', data=data)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error refreshing Spotify token: {response.text}")
return None
def get_current_user_spotify_token():
"""Get a valid Spotify access token for the current user, refreshing if needed"""
if not current_user or not current_user.is_authenticated:
current_app.logger.error("No authenticated user")
return None
# Check if token exists and is valid
if (current_user.spotify_token and
current_user.spotify_token_expiry and
current_user.spotify_token_expiry > datetime.now() + timedelta(minutes=5)):
# Token is valid and not about to expire
current_app.logger.debug(f"Using valid Spotify token for user {current_user.id}")
return current_user.spotify_token
# Token is missing or about to expire - try to refresh
if current_user.spotify_refresh_token:
current_app.logger.info(f"Refreshing Spotify token for user {current_user.id}")
# Try to refresh the token
token_info = refresh_spotify_token(current_user.spotify_refresh_token)
if token_info and 'access_token' in token_info:
# Update token in database
current_user.spotify_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600) # Default to 1 hour if not specified
current_user.spotify_token_expiry = datetime.now() + timedelta(seconds=expires_in)
# Update refresh token if a new one was provided
if 'refresh_token' in token_info:
current_user.spotify_refresh_token = token_info['refresh_token']
db.session.commit()
current_app.logger.info(f"Successfully refreshed Spotify token for user {current_user.id}")
return current_user.spotify_token
# If we get here, we couldn't refresh the user's token
current_app.logger.error(f"Failed to get valid Spotify token for user {current_user.id}")
return None
def get_system_spotify_token():
"""Get a valid Spotify access token from system refresh token, refreshing if needed"""
system_refresh_token = SystemSetting.get('fallback_spotify_refresh_token', '')
if not system_refresh_token:
current_app.logger.debug("No system Spotify refresh token available")
return None
# Check if we have a cached system token that's still valid
system_token = SystemSetting.get('system_spotify_token', '')
system_token_expiry_str = SystemSetting.get('system_spotify_token_expiry', '')
if system_token and system_token_expiry_str:
try:
system_token_expiry = datetime.fromisoformat(system_token_expiry_str)
if system_token_expiry > datetime.now() + timedelta(minutes=5):
current_app.logger.debug("Using valid cached system Spotify token")
return system_token
except ValueError:
current_app.logger.warning("Invalid system token expiry format")
# Token is missing or about to expire - try to refresh
current_app.logger.info("Refreshing system Spotify token")
token_info = refresh_spotify_token(system_refresh_token)
if token_info and 'access_token' in token_info:
# Cache the new token
new_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600) # Default to 1 hour if not specified
expiry = datetime.now() + timedelta(seconds=expires_in)
SystemSetting.set('system_spotify_token', new_token)
SystemSetting.set('system_spotify_token_expiry', expiry.isoformat())
# Update refresh token if a new one was provided
if 'refresh_token' in token_info:
SystemSetting.set('fallback_spotify_refresh_token', token_info['refresh_token'])
current_app.logger.info("Successfully refreshed system Spotify token")
return new_token
# If we get here, we couldn't refresh the system token
current_app.logger.error("Failed to get valid system Spotify token")
return None
def get_spotify_token():
"""
Get the best available Spotify token with automatic refresh
Priority: User token -> System token -> None
"""
# Try user token first
user_token = get_current_user_spotify_token()
if user_token:
return user_token, 'user'
# Fall back to system token
system_token = get_system_spotify_token()
if system_token:
return system_token, 'system'
# No valid tokens available
current_app.logger.warning("No valid Spotify tokens available")
return None, 'none'
def refresh_spotify_token_if_needed(token, refresh_token, token_expiry):
"""
Check if a token needs refresh and refresh it if necessary
Returns: (new_token, new_refresh_token, new_expiry) or (None, None, None) if failed
"""
# Check if token is still valid
if token and token_expiry and token_expiry > datetime.now() + timedelta(minutes=5):
return token, refresh_token, token_expiry
# Token needs refresh
if not refresh_token:
current_app.logger.error("Token expired but no refresh token available")
return None, None, None
token_info = refresh_spotify_token(refresh_token)
if token_info and 'access_token' in token_info:
new_token = token_info['access_token']
expires_in = token_info.get('expires_in', 3600)
new_expiry = datetime.now() + timedelta(seconds=expires_in)
new_refresh_token = token_info.get('refresh_token', refresh_token)
return new_token, new_refresh_token, new_expiry
return None, None, None
def get_spotify_user_info(access_token):
"""Get Spotify user info using an access token"""
if not access_token:
return None
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json'
}
try:
response = requests.get('https://api.spotify.com/v1/me', headers=headers)
if response.status_code == 200:
return response.json()
else:
current_app.logger.error(f"Error getting Spotify user info: {response.status_code} - {response.text}")
return None
except Exception as e:
current_app.logger.error(f"Exception getting Spotify user info: {str(e)}")
return None
View File
+1 -1
View File
@@ -57,7 +57,7 @@ class User(db.Model, UserMixin):
auth_provider = db.Column(db.String(20), default='local') # 'local', 'google', 'authentik'
# OAuth provider info - Spotify
oauth_id = db.Column(db.String(100)) # Spotify user ID
spotify_id = db.Column(db.String(100), index=True, unique=True, nullable=True) # Spotify user ID
spotify_token = db.Column(db.Text) # Store Spotify access token
spotify_refresh_token = db.Column(db.Text) # Store Spotify refresh token
spotify_token_expiry = db.Column(db.DateTime)
+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
+17 -3
View File
@@ -201,9 +201,23 @@
</div>
</div>
</nav>
</header>
<main class="flex-grow container mx-auto px-4 py-6">
</header> <main class="flex-grow container mx-auto px-4 py-6">
<!-- Flash Messages - Global display for all pages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6">
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% elif category == 'warning' %}bg-yellow-100 text-yellow-700{% elif category == 'info' %}bg-blue-100 text-blue-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded shadow-sm">
<div class="flex items-center justify-between">
<span>{{ message }}</span>
<button onclick="this.parentElement.parentElement.style.display='none'" class="text-lg leading-none hover:opacity-75 ml-2">&times;</button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}
{% endblock %}
</main>
+1 -13
View File
@@ -7,19 +7,7 @@
<div class="mb-8">
<h1 class="text-3xl font-bold text-navy-800 font-montserrat mb-2">Import Playlist</h1>
<p class="text-gray-600">Create a music quiz round from a Spotify or Deezer playlist.</p>
</div>
<div class="bg-white shadow-md rounded-lg p-6">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-4 border-l-4 {% if category == 'error' %}border-red-500 bg-red-50 text-red-700{% else %}border-green-500 bg-green-50 text-green-700{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div> <div class="bg-white shadow-md rounded-lg p-6">
<form method="POST" action="{{ url_for('generate.import_playlist') }}" id="importPlaylistForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
+1 -11
View File
@@ -538,17 +538,7 @@
// Close toast button event
toastCloseBtn.addEventListener('click', hideToast);
// Process flash messages from server
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
showToast('{{ message|safe }}', '{{ category }}');
{% endfor %}
{% endif %}
{% endwith %}
// Process email error if any
// Process email error if any
{% if email_error %}
showToast('{{ email_error }}', 'error');
{% endif %}
@@ -1,172 +0,0 @@
{% extends 'base.html' %}
{% block title %}Spotify Client Test{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2 text-navy-800 font-montserrat">Spotify Client Comparison</h1>
<p class="text-gray-600 mb-6">Comparing spotipy library vs direct API implementation.</p>
<!-- Account Selection -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<form method="GET" action="{{ url_for('import.test_spotify_client') }}" class="space-y-4">
<div>
<label for="account" class="block text-sm font-medium text-gray-700 mb-1">Spotify Account</label>
<select name="account" id="account"
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-teal-500 focus:border-teal-500">
<option value="spotify" {% if account == 'spotify' %}selected{% endif %}>spotify</option>
<option value="spotifycharts" {% if account == 'spotifycharts' %}selected{% endif %}>spotifycharts</option>
<option value="spotifymaps" {% if account == 'spotifymaps' %}selected{% endif %}>spotifymaps</option>
<option value="spotifyuk" {% if account == 'spotifyuk' %}selected{% endif %}>spotifyuk</option>
<option value="spotifyusa" {% if account == 'spotifyusa' %}selected{% endif %}>spotifyusa</option>
<option value="spotify_germany" {% if account == 'spotify_germany' %}selected{% endif %}>spotify_germany</option>
</select>
</div>
<div>
<button type="submit" class="bg-orange-500 hover:bg-orange-600 text-white py-2 px-4 rounded-md shadow-sm transition-colors">
Test Account
</button>
</div>
</form>
</div>
<!-- Results Summary -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-xl font-bold mb-4">Results Summary</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Spotipy Results -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">Spotipy Implementation</h3>
<div class="space-y-2">
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Playlists Retrieved:</span>
<span class="font-medium">{{ results.spotipy.count }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Expected Total:</span>
<span class="font-medium">{{ results.spotipy.total }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Execution Time:</span>
<span class="font-medium">{{ results.spotipy.time_ms }} ms</span>
</div>
{% if results.spotipy.error %}
<div class="mt-2 p-2 bg-red-50 text-red-700 rounded">
<strong>Error:</strong> {{ results.spotipy.error }}
</div>
{% endif %}
</div>
</div>
<!-- Direct API Results -->
<div class="border border-gray-200 rounded-lg p-4">
<h3 class="text-lg font-semibold mb-2">Direct API Implementation</h3>
<div class="space-y-2">
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Playlists Retrieved:</span>
<span class="font-medium">{{ results.direct.count }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Expected Total:</span>
<span class="font-medium">{{ results.direct.total }}</span>
</div>
<div class="flex justify-between border-b border-gray-100 pb-1">
<span class="text-gray-700">Execution Time:</span>
<span class="font-medium">{{ results.direct.time_ms }} ms</span>
</div>
{% if results.direct.error %}
<div class="mt-2 p-2 bg-red-50 text-red-700 rounded">
<strong>Error:</strong> {{ results.direct.error }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Comparison -->
<div class="bg-white shadow-md rounded-lg p-6 mb-8">
<h2 class="text-xl font-bold mb-4">Implementation Comparison</h2>
<div class="space-y-4">
<div class="flex items-center">
<div class="w-1/3 font-medium">Playlists in both implementations:</div>
<div class="w-2/3">{{ comparison.in_both|length }}</div>
</div>
<div class="flex items-center">
<div class="w-1/3 font-medium">Only in spotipy:</div>
<div class="w-2/3">{{ comparison.only_in_spotipy|length }}</div>
</div>
<div class="flex items-center">
<div class="w-1/3 font-medium">Only in direct API:</div>
<div class="w-2/3">{{ comparison.only_in_direct|length }}</div>
</div>
</div>
</div>
<!-- Playlist Details -->
<div class="flex flex-col md:flex-row gap-6">
<!-- Spotipy Playlists -->
<div class="w-full md:w-1/2">
<div class="bg-white shadow-md rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Spotipy Playlists ({{ results.spotipy.count }})</h3>
{% if results.spotipy.playlists %}
<div class="overflow-y-auto max-h-96">
<table class="min-w-full">
<thead>
<tr>
<th class="px-4 py-2 border-b text-left">#</th>
<th class="px-4 py-2 border-b text-left">Name</th>
</tr>
</thead>
<tbody>
{% for playlist in results.spotipy.playlists %}
<tr>
<td class="px-4 py-2 border-b">{{ loop.index }}</td>
<td class="px-4 py-2 border-b">{{ playlist.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-500">No playlists retrieved.</p>
{% endif %}
</div>
</div>
<!-- Direct API Playlists -->
<div class="w-full md:w-1/2">
<div class="bg-white shadow-md rounded-lg p-6">
<h3 class="text-lg font-semibold mb-4">Direct API Playlists ({{ results.direct.count }})</h3>
{% if results.direct.playlists %}
<div class="overflow-y-auto max-h-96">
<table class="min-w-full">
<thead>
<tr>
<th class="px-4 py-2 border-b text-left">#</th>
<th class="px-4 py-2 border-b text-left">Name</th>
</tr>
</thead>
<tbody>
{% for playlist in results.direct.playlists %}
<tr>
<td class="px-4 py-2 border-b">{{ loop.index }}</td>
<td class="px-4 py-2 border-b">{{ playlist.name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-gray-500">No playlists retrieved.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -9,12 +9,6 @@
<p class="text-gray-600">Upload or generate your own intro, outro, and replay announcements for quizzes.</p>
</div>
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="mb-6 p-4 rounded {% if category == 'danger' %}bg-red-50 text-red-700 border border-red-300{% elif category == 'success' %}bg-green-50 text-green-700 border border-green-300{% else %}bg-blue-50 text-blue-700 border border-blue-300{% endif %}">
{{ message }}
</div>
{% endfor %}
<!-- Intro Audio Section -->
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
<div class="bg-navy-50 p-4 border-b">
@@ -6,16 +6,6 @@
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">Change Password</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('users.change_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -6,16 +6,6 @@
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">Edit Profile</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('users.edit_profile') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -13,14 +13,7 @@
Enter your email address and we'll send a reset link
</p>
</div>
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="mb-4 p-4 rounded-md {% if category == 'danger' %}bg-red-50 text-red-700{% elif category == 'success' %}bg-green-50 text-green-700{% else %}bg-blue-50 text-blue-700{% endif %}">
{{ message }}
</div>
{% endfor %}
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
<form class="space-y-6" action="{{ url_for('users.forgot_password') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
-10
View File
@@ -6,16 +6,6 @@
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">Login to Quizzical Beats</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('users.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -0,0 +1,142 @@
{% extends 'base.html' %}
{% block title %}Connect Spotify - Quizzical Beats{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">Connect Spotify Account</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left column: Current status -->
<div class="bg-gray-50 p-6 rounded-lg">
<h3 class="text-lg font-semibold mb-4 text-navy-700">Spotify Connection Status</h3>
{% if current_user.spotify_token %}
<div class="mb-6 flex items-center">
<div class="mr-4 bg-green-100 p-3 rounded-full">
<i class="fab fa-spotify text-2xl text-green-600"></i>
</div>
<div>
<p class="font-medium text-green-700">Connected to Spotify</p>
<p class="text-sm text-gray-600">Your account is linked to Spotify</p>
{% if spotify_user_info %}
<p class="text-sm text-gray-600 mt-1">Spotify ID: {{ spotify_user_info.id }}</p>
<p class="text-sm text-gray-600 mt-1">Display Name: {{ spotify_user_info.display_name }}</p>
{% if spotify_user_info.email %}
<p class="text-sm text-gray-600 mt-1">Email: {{ spotify_user_info.email }}</p>
{% endif %}
{% if spotify_user_info.images and spotify_user_info.images[0] %}
<img src="{{ spotify_user_info.images[0].url }}" alt="Spotify Profile Image" class="rounded-full mt-2" style="width:64px;height:64px;">
{% endif %}
{% endif %}
</div>
</div>
<div>
<h4 class="text-md font-medium mb-2">Token Information</h4>
<div class="bg-white p-4 rounded-lg border border-gray-200 text-sm">
<p class="flex justify-between mb-2">
<span class="font-medium">Token Status:</span>
<span class="{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}text-green-600{% else %}text-red-600{% endif %}">
{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}
Valid
{% else %}
Expired
{% endif %}
</span>
</p>
{% if current_user.spotify_token_expiry %}
<p class="flex justify-between">
<span class="font-medium">Expires:</span>
<span>{{ current_user.spotify_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</p>
{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('users.spotify_disconnect') }}" class="mt-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-md font-medium">
<i class="fas fa-unlink mr-2"></i> Disconnect Spotify
</button>
</form>
{% else %}
<div class="mb-6 flex items-center">
<div class="mr-4 bg-gray-200 p-3 rounded-full">
<i class="fab fa-spotify text-2xl text-gray-500"></i>
</div>
<div>
<p class="font-medium text-gray-700">Not Connected</p>
<p class="text-sm text-gray-600">Your account is not linked to Spotify</p>
</div>
</div>
<form method="POST" action="{{ url_for('users.spotify_link') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="inline-block bg-[#1DB954] hover:bg-[#1ed760] text-white font-medium py-2 px-4 rounded-md">
<i class="fab fa-spotify mr-2"></i> Connect with Spotify
</button>
</form>
{% endif %}
</div>
<!-- Right column: Info and benefits -->
<div>
<h3 class="text-lg font-semibold mb-4 text-navy-700">Why Connect Spotify?</h3>
<div class="bg-teal-50 border-l-4 border-teal-500 p-4 rounded mb-6">
<p class="text-teal-700">
Connecting your Spotify account enhances your music quiz creation experience
</p>
</div>
<div class="space-y-4">
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-music"></i>
</div>
<div>
<h4 class="font-medium">Access Your Playlists</h4>
<p class="text-gray-600 text-sm">Import songs from your personal Spotify playlists</p>
</div>
</div>
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-search"></i>
</div>
<div>
<h4 class="font-medium">Search the Spotify Catalog</h4>
<p class="text-gray-600 text-sm">Find and import any track from Spotify's huge library</p>
</div>
</div>
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-chart-line"></i>
</div>
<div>
<h4 class="font-medium">Trending Playlists</h4>
<p class="text-gray-600 text-sm">Access Spotify's official and trending playlists</p>
</div>
</div>
</div>
<div class="mt-8 bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium mb-2">Privacy Note</h4>
<p class="text-sm text-gray-600">
We only access your Spotify data to help you create music quizzes.
We don't share your information or post to your account.
</p>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<a href="{{ url_for('users.profile') }}" class="text-teal-600 hover:text-teal-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Profile
</a>
</div>
</div>
{% endblock %}
-10
View File
@@ -6,16 +6,6 @@
<div class="max-w-4xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">My Profile</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Profile Info -->
<div class="md:col-span-2">
-10
View File
@@ -6,16 +6,6 @@
<div class="max-w-md mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">Create an Account</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('users.register') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -13,14 +13,7 @@
Please enter your new password below
</p>
</div>
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
{% for category, message in get_flashed_messages(with_categories=true) %}
<div class="mb-4 p-4 rounded-md {% if category == 'danger' %}bg-red-50 text-red-700{% elif category == 'success' %}bg-green-50 text-green-700{% else %}bg-blue-50 text-blue-700{% endif %}">
{{ message }}
</div>
{% endfor %}
<div class="mt-8 bg-white py-8 px-6 shadow-md rounded-lg">
<form class="space-y-6" action="{{ url_for('users.reset_password', token=token) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
@@ -1,143 +0,0 @@
{% extends 'base.html' %}
{% block title %}Connect Spotify - Quizzical Beats{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto my-8 p-6 bg-white rounded-lg shadow-md">
<h2 class="text-2xl font-bold mb-6 text-navy-800">Connect Spotify Account</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-3 {% if category == 'danger' %}bg-red-100 text-red-700{% elif category == 'success' %}bg-green-100 text-green-700{% else %}bg-blue-100 text-blue-700{% endif %} rounded">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Left column: Current status -->
<div class="bg-gray-50 p-6 rounded-lg">
<h3 class="text-lg font-semibold mb-4 text-navy-700">Spotify Connection Status</h3>
{% if current_user.spotify_token %}
<div class="mb-6 flex items-center">
<div class="mr-4 bg-green-100 p-3 rounded-full">
<i class="fab fa-spotify text-2xl text-green-600"></i>
</div>
<div>
<p class="font-medium text-green-700">Connected to Spotify</p>
<p class="text-sm text-gray-600">Your account is linked to Spotify</p>
{% if current_user.oauth_id %}
<p class="text-sm text-gray-600 mt-1">Spotify ID: {{ current_user.oauth_id }}</p>
{% endif %}
</div>
</div>
<div>
<h4 class="text-md font-medium mb-2">Token Information</h4>
<div class="bg-white p-4 rounded-lg border border-gray-200 text-sm">
<p class="flex justify-between mb-2">
<span class="font-medium">Token Status:</span>
<span class="{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}text-green-600{% else %}text-red-600{% endif %}">
{% if current_user.spotify_token_expiry and current_user.spotify_token_expiry > now %}
Valid
{% else %}
Expired
{% endif %}
</span>
</p>
{% if current_user.spotify_token_expiry %}
<p class="flex justify-between">
<span class="font-medium">Expires:</span>
<span>{{ current_user.spotify_token_expiry.strftime('%Y-%m-%d %H:%M:%S') }}</span>
</p>
{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('users.spotify_link') }}" class="mt-6">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<input type="hidden" name="action" value="disconnect">
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white py-2 px-4 rounded-md font-medium">
<i class="fas fa-unlink mr-2"></i> Disconnect Spotify
</button>
</form>
{% else %}
<div class="mb-6 flex items-center">
<div class="mr-4 bg-gray-200 p-3 rounded-full">
<i class="fab fa-spotify text-2xl text-gray-500"></i>
</div>
<div>
<p class="font-medium text-gray-700">Not Connected</p>
<p class="text-sm text-gray-600">Your account is not linked to Spotify</p>
</div>
</div>
<a href="{{ url_for('users.spotify_auth') }}" class="inline-block bg-[#1DB954] hover:bg-[#1ed760] text-white font-medium py-2 px-4 rounded-md">
<i class="fab fa-spotify mr-2"></i> Connect with Spotify
</a>
{% endif %}
</div>
<!-- Right column: Info and benefits -->
<div>
<h3 class="text-lg font-semibold mb-4 text-navy-700">Why Connect Spotify?</h3>
<div class="bg-teal-50 border-l-4 border-teal-500 p-4 rounded mb-6">
<p class="text-teal-700">
Connecting your Spotify account enhances your music quiz creation experience
</p>
</div>
<div class="space-y-4">
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-music"></i>
</div>
<div>
<h4 class="font-medium">Access Your Playlists</h4>
<p class="text-gray-600 text-sm">Import songs from your personal Spotify playlists</p>
</div>
</div>
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-search"></i>
</div>
<div>
<h4 class="font-medium">Search the Spotify Catalog</h4>
<p class="text-gray-600 text-sm">Find and import any track from Spotify's huge library</p>
</div>
</div>
<div class="flex">
<div class="mr-3 text-teal-500">
<i class="fas fa-chart-line"></i>
</div>
<div>
<h4 class="font-medium">Trending Playlists</h4>
<p class="text-gray-600 text-sm">Access Spotify's official and trending playlists</p>
</div>
</div>
</div>
<div class="mt-8 bg-gray-50 p-4 rounded-lg">
<h4 class="font-medium mb-2">Privacy Note</h4>
<p class="text-sm text-gray-600">
We only access your Spotify data to help you create music quizzes.
We don't share your information or post to your account.
</p>
</div>
</div>
</div>
<div class="mt-6 pt-6 border-t border-gray-200">
<a href="{{ url_for('users.profile') }}" class="text-teal-600 hover:text-teal-800">
<i class="fas fa-arrow-left mr-2"></i> Back to Profile
</a>
</div>
</div>
{% endblock %}