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