Initial clean commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
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 musicround.models import User, db
|
||||
from datetime import datetime
|
||||
import spotipy
|
||||
|
||||
# Create blueprint
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/')
|
||||
def index():
|
||||
"""Landing page"""
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('core.index'))
|
||||
return render_template('auth/index.html')
|
||||
|
||||
@auth_bp.route('/login')
|
||||
def login():
|
||||
"""Redirect to user login page"""
|
||||
return redirect(url_for('users.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
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('core.index'))
|
||||
|
||||
# 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)
|
||||
|
||||
@auth_bp.route('/callback')
|
||||
def callback():
|
||||
"""Handle Spotify OAuth callback for login"""
|
||||
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
|
||||
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")
|
||||
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'))
|
||||
|
||||
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'))
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Core routes that form the basic navigation structure of the app.
|
||||
"""
|
||||
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
|
||||
|
||||
core_bp = Blueprint('core', __name__)
|
||||
|
||||
@core_bp.route('/')
|
||||
def index():
|
||||
"""
|
||||
If user is not logged in, show login.
|
||||
Otherwise, show homepage with user info.
|
||||
"""
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('users.login'))
|
||||
|
||||
return render_template('homepage.html', user_info={'display_name': current_user.username})
|
||||
|
||||
@core_bp.route('/search', methods=['GET'])
|
||||
@login_required
|
||||
def search():
|
||||
"""
|
||||
Show search page for Spotify
|
||||
"""
|
||||
return render_template('service_search.html',
|
||||
service_name='Spotify',
|
||||
search_results_url=url_for('core.search_results'),
|
||||
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'),
|
||||
url_placeholder="https://open.spotify.com/track/...")
|
||||
|
||||
@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'))
|
||||
|
||||
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}")
|
||||
|
||||
# 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}
|
||||
]
|
||||
|
||||
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}")
|
||||
|
||||
# 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
|
||||
|
||||
# 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']
|
||||
})
|
||||
|
||||
# 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_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)
|
||||
|
||||
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,
|
||||
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'))
|
||||
|
||||
except Exception as e:
|
||||
# Log the detailed error
|
||||
import traceback
|
||||
current_app.logger.error(f"Spotify search error: {str(e)}")
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
|
||||
# Render error template
|
||||
return render_template('error.html',
|
||||
error_message="An error occurred while searching Spotify.",
|
||||
error_details=str(e),
|
||||
back_url=url_for('core.search'))
|
||||
|
||||
@core_bp.route('/view-songs')
|
||||
@login_required
|
||||
def view_songs():
|
||||
"""
|
||||
Show all songs in database
|
||||
"""
|
||||
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)
|
||||
|
||||
@core_bp.route('/data/<path:filepath>')
|
||||
@login_required
|
||||
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
|
||||
|
||||
return send_from_directory('/data', filepath)
|
||||
@@ -0,0 +1,190 @@
|
||||
from flask import Blueprint, redirect, url_for, flash, jsonify
|
||||
from flask_admin import Admin, BaseView, expose
|
||||
from flask_admin.contrib.sqla import ModelView
|
||||
from flask_admin.contrib.fileadmin import FileAdmin
|
||||
from flask_admin.menu import MenuLink
|
||||
from flask_admin.actions import action
|
||||
from flask_login import current_user, login_required
|
||||
from musicround.models import Song, Tag, SongTag, Round, User, Role, UserPreferences, SystemSetting, db
|
||||
from functools import wraps
|
||||
import os
|
||||
import json
|
||||
|
||||
# Create a basic authentication wrapper
|
||||
def admin_required(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Check if user is logged in and is an admin
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('users.login'))
|
||||
|
||||
if not current_user.is_admin():
|
||||
flash('Admin access required.', 'danger')
|
||||
return redirect(url_for('core.index'))
|
||||
|
||||
return view_func(*args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
# Create the blueprint
|
||||
db_admin_bp = Blueprint('db_admin', __name__, url_prefix='/admin')
|
||||
|
||||
# Define routes on the blueprint before it gets registered
|
||||
@db_admin_bp.route('/raw')
|
||||
@admin_required
|
||||
def raw_db_access():
|
||||
return redirect(url_for('admin.index'))
|
||||
|
||||
# Base model view with authentication
|
||||
class AuthModelView(ModelView):
|
||||
def is_accessible(self):
|
||||
return current_user.is_authenticated and current_user.is_admin()
|
||||
|
||||
def inaccessible_callback(self, name, **kwargs):
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('users.login'))
|
||||
return redirect(url_for('core.index'))
|
||||
|
||||
# Add basic search functionality to all models
|
||||
column_searchable_list = []
|
||||
column_filters = []
|
||||
|
||||
# Enable export to CSV
|
||||
can_export = True
|
||||
export_types = ['csv', 'json']
|
||||
|
||||
# Enhanced Song ModelView
|
||||
class SongModelView(AuthModelView):
|
||||
column_searchable_list = ['title', 'artist', 'spotify_id']
|
||||
column_filters = ['title', 'artist', 'year', 'genre', 'used_count']
|
||||
column_default_sort = ('id', False)
|
||||
|
||||
@action('reset_used_count', 'Reset Used Count', 'Are you sure you want to reset used count to 0?')
|
||||
def action_reset_used_count(self, ids):
|
||||
try:
|
||||
query = Song.query.filter(Song.id.in_(ids))
|
||||
|
||||
# Update all songs
|
||||
for song in query.all():
|
||||
song.used_count = 0
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Used count reset for {len(ids)} songs.', 'success')
|
||||
except Exception as ex:
|
||||
db.session.rollback()
|
||||
flash(f'Error resetting used count: {str(ex)}', 'danger')
|
||||
|
||||
# Enhanced Round ModelView
|
||||
class RoundModelView(AuthModelView):
|
||||
column_searchable_list = ['name', 'round_type', 'round_criteria_used']
|
||||
column_filters = ['name', 'round_type', 'round_criteria_used']
|
||||
column_list = ['id', 'name', 'round_type', 'round_criteria_used', 'created_at', 'mp3_generated', 'pdf_generated']
|
||||
column_default_sort = ('id', False)
|
||||
|
||||
# Enhanced Tag ModelView
|
||||
class TagModelView(AuthModelView):
|
||||
column_searchable_list = ['name']
|
||||
column_filters = ['name']
|
||||
|
||||
# Enhanced SongTag ModelView
|
||||
class SongTagModelView(AuthModelView):
|
||||
column_filters = ['song_id', 'tag_id']
|
||||
|
||||
# Enhanced User ModelView
|
||||
class UserModelView(AuthModelView):
|
||||
column_searchable_list = ['username', 'email', 'first_name', 'last_name']
|
||||
column_filters = ['username', 'email', 'active', 'created_at', 'last_login']
|
||||
column_default_sort = ('id', False)
|
||||
|
||||
# Protect password field in forms
|
||||
form_excluded_columns = ['password_hash', 'reset_token', 'reset_token_expiry']
|
||||
|
||||
@action('activate_users', 'Activate Users', 'Are you sure you want to activate selected users?')
|
||||
def action_activate_users(self, ids):
|
||||
try:
|
||||
query = User.query.filter(User.id.in_(ids))
|
||||
|
||||
# Update all selected users
|
||||
for user in query.all():
|
||||
user.active = True
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Successfully activated {len(ids)} users.', 'success')
|
||||
except Exception as ex:
|
||||
db.session.rollback()
|
||||
flash(f'Error activating users: {str(ex)}', 'danger')
|
||||
|
||||
@action('deactivate_users', 'Deactivate Users', 'Are you sure you want to deactivate selected users?')
|
||||
def action_deactivate_users(self, ids):
|
||||
try:
|
||||
query = User.query.filter(User.id.in_(ids))
|
||||
|
||||
# Update all selected users
|
||||
for user in query.all():
|
||||
user.active = False
|
||||
|
||||
db.session.commit()
|
||||
flash(f'Successfully deactivated {len(ids)} users.', 'success')
|
||||
except Exception as ex:
|
||||
db.session.rollback()
|
||||
flash(f'Error deactivating users: {str(ex)}', 'danger')
|
||||
|
||||
# Enhanced Role ModelView
|
||||
class RoleModelView(AuthModelView):
|
||||
column_searchable_list = ['name', 'description']
|
||||
column_filters = ['name']
|
||||
|
||||
# Enhanced UserPreferences ModelView
|
||||
class UserPreferencesModelView(AuthModelView):
|
||||
column_filters = ['user_id', 'theme', 'enable_intro']
|
||||
|
||||
# Enhanced SystemSetting ModelView
|
||||
class SystemSettingModelView(AuthModelView):
|
||||
column_searchable_list = ['key']
|
||||
column_filters = ['key']
|
||||
column_exclude_list = [] # Ensure any sensitive values are not excluded if needed
|
||||
|
||||
# Initialize the admin interface
|
||||
admin = None
|
||||
|
||||
def init_admin(app):
|
||||
"""Initialize the admin interface with the Flask app."""
|
||||
global admin
|
||||
|
||||
# Set Flask-Admin configuration
|
||||
app.config['FLASK_ADMIN_SWATCH'] = 'cerulean' # Use a Bootstrap swatch theme
|
||||
|
||||
# Create admin interface
|
||||
admin = Admin(
|
||||
app,
|
||||
name='MusicRound Admin',
|
||||
template_mode='bootstrap3',
|
||||
url='/admin'
|
||||
)
|
||||
|
||||
# Add model views
|
||||
# Data models
|
||||
admin.add_view(SongModelView(Song, db.session, category="Music Data"))
|
||||
admin.add_view(TagModelView(Tag, db.session, category="Music Data"))
|
||||
admin.add_view(SongTagModelView(SongTag, db.session, category="Music Data"))
|
||||
admin.add_view(RoundModelView(Round, db.session, category="Music Data"))
|
||||
|
||||
# User management
|
||||
admin.add_view(UserModelView(User, db.session, category="User Management"))
|
||||
admin.add_view(RoleModelView(Role, db.session, category="User Management"))
|
||||
admin.add_view(UserPreferencesModelView(UserPreferences, db.session, category="User Management"))
|
||||
|
||||
# System
|
||||
admin.add_view(SystemSettingModelView(SystemSetting, db.session, category="System"))
|
||||
|
||||
# Add file admin for audio files
|
||||
path = os.path.join(os.path.dirname(__file__), '../static/audio')
|
||||
admin.add_view(FileAdmin(path, '/static/audio/', name='Audio Files', category="System", endpoint='static_audio_files'))
|
||||
|
||||
# User MP3 files
|
||||
user_mp3_path = os.path.join(os.path.dirname(__file__), '../mp3')
|
||||
admin.add_view(FileAdmin(user_mp3_path, '/mp3/', name='User MP3 Files', category="System", endpoint='user_mp3_files'))
|
||||
|
||||
# Add links
|
||||
admin.add_link(MenuLink(name='Back to App', url='/'))
|
||||
|
||||
return admin
|
||||
@@ -0,0 +1,223 @@
|
||||
from flask import Blueprint, render_template, redirect, url_for, request, current_app, flash, session, jsonify
|
||||
from musicround.models import Song, db
|
||||
import deezer
|
||||
import musicbrainzngs
|
||||
import requests
|
||||
import openai
|
||||
import os
|
||||
import json
|
||||
from musicround.helpers.metadata import get_song_metadata_by_isrc
|
||||
from musicround.helpers.import_helper import ImportHelper
|
||||
|
||||
deezer_bp = Blueprint('deezer', __name__)
|
||||
|
||||
@deezer_bp.route('/deezer-search', methods=['GET'])
|
||||
def deezer_search():
|
||||
"""Display Deezer search form"""
|
||||
return render_template('service_search.html',
|
||||
service_name='Deezer',
|
||||
search_results_url=url_for('deezer.deezer_search_results'),
|
||||
browse_playlists_url=url_for('deezer.browse_deezer_playlists'),
|
||||
track_import_url=url_for('deezer.import_deezer_track_result'),
|
||||
album_import_url=url_for('deezer.import_deezer_album_result'),
|
||||
playlist_import_url=url_for('deezer.import_deezer_playlist_result'),
|
||||
url_placeholder='https://www.deezer.com/...')
|
||||
|
||||
@deezer_bp.route('/deezer-search-results', methods=['POST'])
|
||||
def deezer_search_results():
|
||||
"""Search for tracks, albums, and playlists on Deezer"""
|
||||
search_term = request.form['search_term']
|
||||
deezer_client = current_app.config['deezer']
|
||||
|
||||
try:
|
||||
tracks = deezer_client.search_tracks(search_term)
|
||||
albums = deezer_client.search_albums(search_term)
|
||||
playlists = deezer_client.search_playlists(search_term)
|
||||
|
||||
# Format tracks for the template
|
||||
formatted_tracks = []
|
||||
for track in tracks:
|
||||
if track:
|
||||
formatted_tracks.append({
|
||||
'id': track.get('id'),
|
||||
'name': track.get('title'),
|
||||
'artist': track.get('artist', {}).get('name', 'Unknown Artist') if track.get('artist') else 'Unknown Artist',
|
||||
'album': track.get('album', {}).get('title', '') if track.get('album') else '',
|
||||
'image_url': track.get('album', {}).get('cover_medium') if track.get('album') else None,
|
||||
'preview_url': track.get('preview')
|
||||
})
|
||||
|
||||
# Format albums for the template
|
||||
formatted_albums = []
|
||||
for album in albums:
|
||||
if album:
|
||||
formatted_albums.append({
|
||||
'id': album.get('id'),
|
||||
'name': album.get('title'),
|
||||
'artist': album.get('artist', {}).get('name', 'Unknown Artist') if album.get('artist') else 'Unknown Artist',
|
||||
'image_url': album.get('cover_medium'),
|
||||
'track_count': album.get('nb_tracks')
|
||||
})
|
||||
|
||||
# Format playlists for the template
|
||||
formatted_playlists = []
|
||||
for playlist in playlists:
|
||||
if playlist:
|
||||
formatted_playlists.append({
|
||||
'id': playlist.get('id'),
|
||||
'name': playlist.get('title'),
|
||||
'owner': playlist.get('user', {}).get('name', 'Unknown') if playlist.get('user') else 'Unknown',
|
||||
'image_url': playlist.get('picture_medium'),
|
||||
'track_count': playlist.get('nb_tracks')
|
||||
})
|
||||
|
||||
# Use the standardized template for search results
|
||||
return render_template('service_search_results.html',
|
||||
service_name='Deezer',
|
||||
search_term=search_term,
|
||||
tracks=formatted_tracks,
|
||||
albums=formatted_albums,
|
||||
playlists=formatted_playlists,
|
||||
tracks_label='Tracks',
|
||||
search_url=url_for('deezer.deezer_search'),
|
||||
has_preview=True,
|
||||
track_import_url=url_for('deezer.import_deezer_track_result'),
|
||||
track_id_field='track_id',
|
||||
album_import_url=url_for('deezer.import_deezer_album_result'),
|
||||
album_id_field='album_id',
|
||||
playlist_import_url=url_for('deezer.import_deezer_playlist_result'),
|
||||
playlist_id_field='playlist_id')
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Deezer search error: {e}")
|
||||
flash("Error searching Deezer. Please try again.", "danger")
|
||||
return redirect(url_for('deezer.deezer_search'))
|
||||
|
||||
@deezer_bp.route('/import-deezer-track', methods=['GET'])
|
||||
def import_deezer_track():
|
||||
"""Display form to import a single track from Deezer"""
|
||||
return render_template('service_import.html',
|
||||
service_name='Deezer',
|
||||
item_type='Track',
|
||||
url_example_prefix='https://www.deezer.com/track/',
|
||||
url_example_id='12345678',
|
||||
id_field='track_id',
|
||||
form_action=url_for('deezer.import_deezer_track_result'),
|
||||
back_url=url_for('deezer.deezer_search'))
|
||||
|
||||
@deezer_bp.route('/import-deezer-track-result', methods=['POST'])
|
||||
def import_deezer_track_result():
|
||||
"""Process the import of a single track from Deezer using the unified ImportHelper"""
|
||||
track_id = request.form['track_id']
|
||||
|
||||
# Use the unified ImportHelper to handle track import
|
||||
result = ImportHelper.import_item('deezer', 'track', track_id)
|
||||
|
||||
if result['imported_count'] > 0:
|
||||
flash(f'Successfully imported {result["imported_count"]} song from Deezer!', 'success')
|
||||
elif result['skipped_count'] > 0:
|
||||
flash('Song was already in the database.', 'info')
|
||||
else:
|
||||
flash(f'Error importing song: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
# Legacy function kept for backward compatibility
|
||||
def import_deezer_track_result_helper(track_id):
|
||||
"""Helper function to import a single track from Deezer"""
|
||||
result = ImportHelper.import_item('deezer', 'track', track_id)
|
||||
return result['imported_count'] > 0
|
||||
|
||||
@deezer_bp.route('/import-deezer-playlist', methods=['GET', 'POST'])
|
||||
def import_deezer_playlist():
|
||||
"""Display form to import all tracks from a Deezer playlist"""
|
||||
if request.method == 'POST':
|
||||
playlist_id = request.form.get('playlist_id')
|
||||
if playlist_id:
|
||||
# Use the unified ImportHelper to handle playlist import
|
||||
result = ImportHelper.import_item('deezer', 'playlist', playlist_id)
|
||||
|
||||
if result['imported_count'] > 0:
|
||||
flash(f'Successfully imported {result["imported_count"]} songs from Deezer 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')
|
||||
else:
|
||||
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
else:
|
||||
flash("Playlist ID is required.", 'error')
|
||||
|
||||
return render_template('service_import.html',
|
||||
service_name='Deezer',
|
||||
item_type='Playlist',
|
||||
url_example_prefix='https://www.deezer.com/playlist/',
|
||||
url_example_id='9876543',
|
||||
id_field='playlist_id',
|
||||
form_action=url_for('deezer.import_deezer_playlist_result'),
|
||||
back_url=url_for('deezer.deezer_search'))
|
||||
|
||||
@deezer_bp.route('/import-deezer-playlist-result', methods=['POST'])
|
||||
def import_deezer_playlist_result():
|
||||
"""Process the import of all tracks from a Deezer playlist using the unified ImportHelper"""
|
||||
playlist_id = request.form['playlist_id']
|
||||
|
||||
# Use the unified ImportHelper to handle playlist import
|
||||
result = ImportHelper.import_item('deezer', 'playlist', playlist_id)
|
||||
|
||||
if result['imported_count'] > 0:
|
||||
flash(f'Successfully imported {result["imported_count"]} songs from Deezer 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')
|
||||
else:
|
||||
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
@deezer_bp.route('/import-deezer-album', methods=['GET'])
|
||||
def import_deezer_album():
|
||||
"""Display form to import all tracks from a Deezer album"""
|
||||
return render_template('service_import.html',
|
||||
service_name='Deezer',
|
||||
item_type='Album',
|
||||
url_example_prefix='https://www.deezer.com/album/',
|
||||
url_example_id='1234567',
|
||||
id_field='album_id',
|
||||
form_action=url_for('deezer.import_deezer_album_result'),
|
||||
back_url=url_for('deezer.deezer_search'))
|
||||
|
||||
@deezer_bp.route('/import-deezer-album-result', methods=['POST'])
|
||||
def import_deezer_album_result():
|
||||
"""Process the import of all tracks from a Deezer album using the unified ImportHelper"""
|
||||
album_id = request.form['album_id']
|
||||
|
||||
# Use the unified ImportHelper to handle album import
|
||||
result = ImportHelper.import_item('deezer', 'album', album_id)
|
||||
|
||||
if result['imported_count'] > 0:
|
||||
flash(f'Successfully imported {result["imported_count"]} songs from Deezer 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')
|
||||
else:
|
||||
flash(f'Error importing album: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
@deezer_bp.route('/browse-deezer-playlists')
|
||||
def browse_deezer_playlists():
|
||||
"""Browse popular playlists on Deezer"""
|
||||
deezer_client = current_app.config['deezer']
|
||||
|
||||
try:
|
||||
playlists = deezer_client.get_popular_playlists()
|
||||
return render_template('browse_deezer_playlists.html', playlists=playlists)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error browsing Deezer playlists: {e}")
|
||||
flash("Error loading Deezer playlists. Please try again.", "danger")
|
||||
return render_template('browse_deezer_playlists.html', playlists=[])
|
||||
@@ -0,0 +1,579 @@
|
||||
import random
|
||||
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
|
||||
|
||||
generate_bp = Blueprint('generate', __name__)
|
||||
|
||||
# Constants
|
||||
songs_per_round = 8
|
||||
|
||||
# Helper functions
|
||||
def get_all_decades():
|
||||
"""
|
||||
Return a list of 'decade' strings (e.g. '1970', '1980')
|
||||
based on the first 3 digits of the year + '0'.
|
||||
"""
|
||||
all_decades = []
|
||||
for song in Song.query.all():
|
||||
if song.year:
|
||||
decade = str(song.year)[:3] + '0'
|
||||
if decade not in all_decades:
|
||||
all_decades.append(decade)
|
||||
return all_decades
|
||||
|
||||
def get_all_genres():
|
||||
"""
|
||||
Return a list of all genres in the Song table.
|
||||
"""
|
||||
all_genres = []
|
||||
for song in Song.query.all():
|
||||
if song.genre and song.genre not in all_genres:
|
||||
all_genres.append(song.genre)
|
||||
return all_genres
|
||||
|
||||
def get_all_tags():
|
||||
"""
|
||||
Return a list of all tag names in the Tag table.
|
||||
"""
|
||||
return [tag.name for tag in Tag.query.all()]
|
||||
|
||||
def get_songs_by_tag(tag_name, limit=8):
|
||||
"""
|
||||
Return songs that have the specified tag.
|
||||
"""
|
||||
tag = Tag.query.filter_by(name=tag_name).first()
|
||||
if tag:
|
||||
return tag.songs[:limit]
|
||||
return []
|
||||
|
||||
def get_least_used_genres():
|
||||
"""
|
||||
Returns a list of genre(s) whose usage count is minimal among all genres.
|
||||
Usage is measured by how many Rounds of type 'genre' reference that genre.
|
||||
"""
|
||||
all_genres_list = get_all_genres()
|
||||
# Start every genre with usage=0
|
||||
genre_usage = {g: 0 for g in all_genres_list}
|
||||
|
||||
# Count how many times each genre appears in Rounds of type 'genre'
|
||||
used_genre_rounds = Round.query.filter_by(round_type='genre').all()
|
||||
for rnd in used_genre_rounds:
|
||||
# Ensure we only increment if it exists in genre_usage
|
||||
if rnd.round_criteria_used in genre_usage:
|
||||
genre_usage[rnd.round_criteria_used] += 1
|
||||
|
||||
# If we have no genres at all, return an empty list
|
||||
if not genre_usage:
|
||||
return []
|
||||
|
||||
# Find the minimal usage count
|
||||
min_usage = min(genre_usage.values())
|
||||
|
||||
# Return all genres that match min_usage
|
||||
return [g for g, usage in genre_usage.items() if usage == min_usage]
|
||||
|
||||
|
||||
def get_least_used_decades():
|
||||
"""
|
||||
Returns a list of decade(s) whose usage count is minimal among all decades.
|
||||
Usage is measured by how many Rounds of type 'decade' reference that decade.
|
||||
"""
|
||||
all_decades_list = get_all_decades()
|
||||
# Start each decade with usage=0
|
||||
decade_usage = {d: 0 for d in all_decades_list}
|
||||
|
||||
# Count how many times each decade appears in Rounds of type 'decade'
|
||||
used_decade_rounds = Round.query.filter_by(round_type='decade').all()
|
||||
for rnd in used_decade_rounds:
|
||||
if rnd.round_criteria_used in decade_usage:
|
||||
decade_usage[rnd.round_criteria_used] += 1
|
||||
|
||||
# If we have no decades at all, return empty
|
||||
if not decade_usage:
|
||||
return []
|
||||
|
||||
# Minimal usage
|
||||
min_usage = min(decade_usage.values())
|
||||
|
||||
# Return all decades that match min_usage
|
||||
return [d for d, usage in decade_usage.items() if usage == min_usage]
|
||||
|
||||
def get_least_used_songs(genre=None, decade=None):
|
||||
"""
|
||||
Returns songs that have never been used in a round.
|
||||
Can filter by genre or decade.
|
||||
"""
|
||||
least_used_songs = []
|
||||
all_songs = Song.query.all()
|
||||
|
||||
# gather round_criteria_used for rounds of type 'song'
|
||||
used_song_ids = []
|
||||
for rnd in Round.query.all():
|
||||
if rnd.round_type == 'song':
|
||||
used_song_ids.append(rnd.round_criteria_used)
|
||||
|
||||
# If a song's spotify_id never appears in used_song_ids => "least used"
|
||||
for song in all_songs:
|
||||
if song.spotify_id not in used_song_ids:
|
||||
least_used_songs.append(song)
|
||||
|
||||
# Filter by genre or decade if passed
|
||||
if genre:
|
||||
least_used_songs = [s for s in least_used_songs if s.genre == genre]
|
||||
if decade:
|
||||
least_used_songs = [s for s in least_used_songs if s.year and str(s.year)[:3] + '0' == decade]
|
||||
return least_used_songs
|
||||
|
||||
def get_non_overused_songs(genre=None, decade=None):
|
||||
"""
|
||||
Returns a list of songs whose used_count is <= the average usage among all songs.
|
||||
Optional filtering by genre or decade.
|
||||
"""
|
||||
all_songs = Song.query.all()
|
||||
total_times_used = sum(song.used_count for song in all_songs) or 1
|
||||
average_times_used = total_times_used / len(all_songs) if len(all_songs) else 1
|
||||
|
||||
# pick songs that are used <= average usage
|
||||
non_overused_songs = [s for s in all_songs if s.used_count <= average_times_used]
|
||||
|
||||
if genre:
|
||||
non_overused_songs = [s for s in non_overused_songs if s.genre == genre]
|
||||
|
||||
if decade:
|
||||
non_overused_songs = [s for s in non_overused_songs if s.year and str(s.year)[:3] + '0' == decade]
|
||||
|
||||
return non_overused_songs
|
||||
|
||||
def get_random_songs_from_genre(genre, x=5):
|
||||
"""
|
||||
Returns x random songs from the given genre,
|
||||
filling from non-overused songs in that genre.
|
||||
If not enough, fallback to any non-overused songs.
|
||||
"""
|
||||
non_overused = get_non_overused_songs(genre=genre)
|
||||
while len(non_overused) < x:
|
||||
more = get_non_overused_songs()
|
||||
if not more: # in case the DB is empty or something else
|
||||
break
|
||||
non_overused.extend(more)
|
||||
|
||||
return random.sample(non_overused, x) if len(non_overused) >= x else non_overused
|
||||
|
||||
def get_random_songs_from_decade(decade, x=5):
|
||||
"""
|
||||
Returns x random songs from the given decade,
|
||||
filling from non-overused songs in that decade.
|
||||
If not enough, fallback to any non-overused songs.
|
||||
"""
|
||||
non_overused = get_non_overused_songs(decade=decade)
|
||||
while len(non_overused) < x:
|
||||
more = get_non_overused_songs()
|
||||
if not more:
|
||||
break
|
||||
non_overused.extend(more)
|
||||
|
||||
return random.sample(non_overused, x) if len(non_overused) >= x else non_overused
|
||||
|
||||
def get_random_songs(x):
|
||||
"""
|
||||
Returns x random songs from the pool of non-overused songs,
|
||||
ensuring some naive diversity constraints:
|
||||
- no artist used more than once
|
||||
- no decade used more than x/3 times
|
||||
- number of unique artists must match number of chosen songs
|
||||
"""
|
||||
non_overused_songs = get_non_overused_songs()
|
||||
if len(non_overused_songs) < x:
|
||||
return non_overused_songs
|
||||
|
||||
random_songs = random.sample(non_overused_songs, x)
|
||||
|
||||
artist_count = {}
|
||||
decade_count = {}
|
||||
|
||||
for song in random_songs:
|
||||
artist_count[song.artist] = artist_count.get(song.artist, 0) + 1
|
||||
if song.year:
|
||||
dec = str(song.year)[:3] + '0'
|
||||
decade_count[dec] = decade_count.get(dec, 0) + 1
|
||||
|
||||
while (
|
||||
max(artist_count.values()) > 1
|
||||
or (decade_count and max(decade_count.values()) > len(random_songs) / 3)
|
||||
or len(artist_count) != len(random_songs)
|
||||
):
|
||||
# 1. If any artist is used more than once, replace that song
|
||||
if max(artist_count.values()) > 1:
|
||||
repeated_artist = None
|
||||
for artist, count in artist_count.items():
|
||||
if count > 1:
|
||||
repeated_artist = artist
|
||||
break
|
||||
if repeated_artist:
|
||||
# remove one of that artist from random_songs
|
||||
to_remove = next(s for s in random_songs if s.artist == repeated_artist)
|
||||
random_songs.remove(to_remove)
|
||||
artist_count[repeated_artist] -= 1
|
||||
|
||||
# pick a new random non-overused
|
||||
refill = [s for s in non_overused_songs if s not in random_songs]
|
||||
if refill:
|
||||
new_song = random.choice(refill)
|
||||
random_songs.append(new_song)
|
||||
artist_count[new_song.artist] = artist_count.get(new_song.artist, 0) + 1
|
||||
|
||||
# update decade_count
|
||||
if to_remove.year:
|
||||
dec_to_remove = str(to_remove.year)[:3] + '0'
|
||||
decade_count[dec_to_remove] = decade_count.get(dec_to_remove, 0) - 1
|
||||
if new_song.year:
|
||||
dec_new = str(new_song.year)[:3] + '0'
|
||||
decade_count[dec_new] = decade_count.get(dec_new, 0) + 1
|
||||
else:
|
||||
# If no new songs available, just return what we have
|
||||
return random_songs
|
||||
|
||||
elif decade_count and max(decade_count.values()) > len(random_songs) / 3:
|
||||
# 2. If any decade is used more than x/3, remove a song from that decade
|
||||
repeated_decade = None
|
||||
for dec, count in decade_count.items():
|
||||
if count > len(random_songs) / 3:
|
||||
repeated_decade = dec
|
||||
break
|
||||
if repeated_decade:
|
||||
to_remove = next((s for s in random_songs if s.year and str(s.year)[:3] + '0' == repeated_decade), None)
|
||||
if to_remove:
|
||||
random_songs.remove(to_remove)
|
||||
decade_count[repeated_decade] -= 1
|
||||
|
||||
# pick a new random
|
||||
refill = [s for s in non_overused_songs if s not in random_songs]
|
||||
if refill:
|
||||
new_song = random.choice(refill)
|
||||
random_songs.append(new_song)
|
||||
if new_song.year:
|
||||
dec_new = str(new_song.year)[:3] + '0'
|
||||
decade_count[dec_new] = decade_count.get(dec_new, 0) + 1
|
||||
|
||||
# update artist_count
|
||||
artist_count[to_remove.artist] -= 1
|
||||
artist_count[new_song.artist] = artist_count.get(new_song.artist, 0) + 1
|
||||
else:
|
||||
# If no new songs available, just return what we have
|
||||
return random_songs
|
||||
|
||||
elif len(artist_count) != len(random_songs):
|
||||
# 3. if there's mismatch in how many unique artists vs. songs, fix that
|
||||
# i.e. if we have a repeated artist but haven't caught it above
|
||||
refill = [s for s in non_overused_songs if s not in random_songs]
|
||||
repeated_song = None
|
||||
# find a repeated artist
|
||||
for s in random_songs:
|
||||
if artist_count[s.artist] > 1:
|
||||
repeated_song = s
|
||||
break
|
||||
if repeated_song is None or not refill:
|
||||
break # fallback
|
||||
|
||||
random_songs.remove(repeated_song)
|
||||
artist_count[repeated_song.artist] -= 1
|
||||
|
||||
new_song = random.choice(refill)
|
||||
random_songs.append(new_song)
|
||||
artist_count[new_song.artist] = artist_count.get(new_song.artist, 0) + 1
|
||||
|
||||
if repeated_song.year:
|
||||
dec_removed = str(repeated_song.year)[:3] + '0'
|
||||
decade_count[dec_removed] = decade_count.get(dec_removed, 0) - 1
|
||||
if new_song.year:
|
||||
dec_new = str(new_song.year)[:3] + '0'
|
||||
decade_count[dec_new] = decade_count.get(dec_new, 0) + 1
|
||||
|
||||
return random_songs
|
||||
|
||||
def get_random_songs_from_least_used_decade(x):
|
||||
"""
|
||||
Returns up to x songs from *one* of the least used decade(s), chosen at random.
|
||||
Returns (songs, chosen_decade).
|
||||
"""
|
||||
candidates = get_least_used_decades()
|
||||
if not candidates:
|
||||
return [], None
|
||||
|
||||
chosen_decade = random.choice(candidates)
|
||||
random_songs = get_random_songs_from_decade(chosen_decade, x=x)
|
||||
|
||||
return random_songs, chosen_decade
|
||||
|
||||
|
||||
def get_random_songs_from_least_used_genre(x):
|
||||
"""
|
||||
Returns up to x songs from *one* of the least used genre(s), chosen at random.
|
||||
Returns (songs, chosen_genre).
|
||||
"""
|
||||
candidates = get_least_used_genres()
|
||||
if not candidates:
|
||||
return [], None
|
||||
|
||||
chosen_genre = random.choice(candidates)
|
||||
random_songs = get_random_songs_from_genre(chosen_genre, x=x)
|
||||
|
||||
return random_songs, chosen_genre
|
||||
|
||||
def get_songs_from_deezer_playlist(playlist_id):
|
||||
"""
|
||||
Fetch songs from a Deezer playlist, properly import them with metadata, and return them
|
||||
"""
|
||||
try:
|
||||
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:
|
||||
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
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching Deezer playlist: {e}")
|
||||
import traceback
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
return []
|
||||
|
||||
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:
|
||||
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
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching Spotify playlist: {e}")
|
||||
import traceback
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
return []
|
||||
|
||||
@generate_bp.route('/build-music-round', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def build_music_round():
|
||||
"""Build a music round based on the selected criteria"""
|
||||
if request.method == 'POST':
|
||||
round_type = request.form['round_type']
|
||||
if round_type == 'Random':
|
||||
round_criteria = 'Random'
|
||||
songs = get_random_songs(songs_per_round)
|
||||
return render_template('round.html', songs=songs, round_criteria=round_criteria)
|
||||
|
||||
elif round_type == 'Decade':
|
||||
round_criteria = 'Least Used Decade'
|
||||
songs, decade_used = get_random_songs_from_least_used_decade(songs_per_round)
|
||||
return render_template(
|
||||
'round.html',
|
||||
songs=songs,
|
||||
round_criteria=round_criteria,
|
||||
decade=decade_used
|
||||
)
|
||||
|
||||
elif round_type == 'Genre':
|
||||
round_criteria = 'Least Used Genre'
|
||||
songs, genre_used = get_random_songs_from_least_used_genre(songs_per_round)
|
||||
return render_template(
|
||||
'round.html',
|
||||
songs=songs,
|
||||
round_criteria=round_criteria,
|
||||
genre=genre_used
|
||||
)
|
||||
|
||||
elif round_type == 'Tag':
|
||||
tag_name = request.form.get('tag_name')
|
||||
if tag_name:
|
||||
round_criteria = f'Tag: {tag_name}'
|
||||
songs = get_songs_by_tag(tag_name, songs_per_round)
|
||||
return render_template(
|
||||
'round.html',
|
||||
songs=songs,
|
||||
round_criteria=round_criteria,
|
||||
tag=tag_name
|
||||
)
|
||||
|
||||
# Pass tag choices to the template for selection
|
||||
tags = get_all_tags()
|
||||
return render_template('build_music_round.html', tags=tags)
|
||||
|
||||
@generate_bp.route('/import-playlist', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def import_playlist():
|
||||
"""Import a playlist from Deezer or Spotify"""
|
||||
if request.method == 'POST':
|
||||
playlist_url = request.form.get('playlist_url', '')
|
||||
platform = request.form.get('platform', '').lower()
|
||||
round_name = request.form.get('round_name', '')
|
||||
|
||||
if not playlist_url:
|
||||
flash('Please enter a playlist URL or ID', 'error')
|
||||
return redirect(url_for('generate.import_playlist'))
|
||||
|
||||
# Extract playlist ID from URL or use as is
|
||||
playlist_id = playlist_url
|
||||
|
||||
if platform == 'deezer':
|
||||
# Extract Deezer playlist ID from URL if needed
|
||||
if 'deezer.com' in playlist_url:
|
||||
try:
|
||||
playlist_id = playlist_url.split('playlist/')[1].split('?')[0]
|
||||
except (IndexError, ValueError):
|
||||
flash('Invalid Deezer playlist URL', 'error')
|
||||
return redirect(url_for('generate.import_playlist'))
|
||||
|
||||
songs = get_songs_from_deezer_playlist(playlist_id)
|
||||
if not songs:
|
||||
flash('No songs found or error fetching playlist from Deezer', 'error')
|
||||
return redirect(url_for('generate.import_playlist'))
|
||||
|
||||
round_criteria = f'Deezer Playlist: {playlist_id}'
|
||||
|
||||
elif platform == 'spotify':
|
||||
# Extract Spotify playlist ID from URL if needed
|
||||
if 'spotify.com' in playlist_url:
|
||||
try:
|
||||
playlist_id = playlist_url.split('playlist/')[1].split('?')[0]
|
||||
except (IndexError, ValueError):
|
||||
flash('Invalid Spotify playlist URL', 'error')
|
||||
return redirect(url_for('generate.import_playlist'))
|
||||
|
||||
songs = get_songs_from_spotify_playlist(playlist_id)
|
||||
if not songs:
|
||||
flash('No songs found or error fetching playlist from Spotify', 'error')
|
||||
return redirect(url_for('generate.import_playlist'))
|
||||
|
||||
round_criteria = f'Spotify Playlist: {playlist_id}'
|
||||
|
||||
else:
|
||||
flash('Please select a valid platform', 'error')
|
||||
return redirect(url_for('generate.import_playlist'))
|
||||
|
||||
return render_template('round.html',
|
||||
songs=songs,
|
||||
round_criteria=round_criteria,
|
||||
round_name=round_name,
|
||||
playlist_import=True)
|
||||
|
||||
return render_template('import_playlist.html')
|
||||
|
||||
@generate_bp.route('/save_round', methods=['POST'])
|
||||
@login_required
|
||||
def save_round():
|
||||
"""
|
||||
Persists a new Round to the DB (with chosen songs).
|
||||
Increments used_count on all chosen songs.
|
||||
"""
|
||||
# get round criteria and name from form
|
||||
round_criteria = request.form.get('round_criteria')
|
||||
round_name = request.form.get('round_name')
|
||||
|
||||
# get optional genre and decade from form
|
||||
genre = request.form.get('genre')
|
||||
decade = request.form.get('decade')
|
||||
tag = request.form.get('tag')
|
||||
|
||||
# get list of song IDs from form
|
||||
song_ids = request.form.getlist('song_id')
|
||||
|
||||
# get list of song objects from database
|
||||
songs = Song.query.filter(Song.id.in_(song_ids)).all()
|
||||
|
||||
# create string representation of song IDs
|
||||
song_ids_str = ','.join(song_id for song_id in song_ids)
|
||||
|
||||
# determine round type
|
||||
if genre:
|
||||
round_type = 'Genre'
|
||||
round_criteria_used = genre
|
||||
elif decade:
|
||||
round_type = 'Decade'
|
||||
round_criteria_used = decade
|
||||
elif tag:
|
||||
round_type = 'Tag'
|
||||
round_criteria_used = tag
|
||||
else:
|
||||
round_type = 'Random'
|
||||
round_criteria_used = 'Random Selection'
|
||||
|
||||
# create new Round object and add to database
|
||||
new_round = Round(
|
||||
name=round_name,
|
||||
round_type=round_type,
|
||||
round_criteria_used=round_criteria_used,
|
||||
songs=song_ids_str,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(new_round)
|
||||
|
||||
# update usage count for each song
|
||||
for song in songs:
|
||||
song.used_count += 1
|
||||
db.session.add(song)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# redirect back to the rounds page
|
||||
return redirect(url_for('rounds.rounds_list'))
|
||||
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Import routes for the Music Round application
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
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
|
||||
|
||||
import_bp = Blueprint('import', __name__, url_prefix='/import')
|
||||
|
||||
def fetch_all_user_playlists(sp, user_id, limit=50):
|
||||
"""
|
||||
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)
|
||||
|
||||
Returns:
|
||||
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}'")
|
||||
|
||||
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)
|
||||
|
||||
# If first request, get the total
|
||||
if total is None:
|
||||
total = results['total']
|
||||
current_app.logger.info(f"User '{user_id}' has {total} playlists in total")
|
||||
|
||||
# Add the current batch of playlists to our collection
|
||||
playlists_batch = results.get('items', [])
|
||||
all_playlists.extend(playlists_batch)
|
||||
|
||||
# Update offset for next batch
|
||||
offset += limit
|
||||
|
||||
# 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 the end
|
||||
if not results.get('next'):
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching playlists for user '{user_id}' at offset {offset}: {e}")
|
||||
break
|
||||
|
||||
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")
|
||||
|
||||
return all_playlists
|
||||
|
||||
def filter_playlists_by_keywords(playlists, keywords, debug_info=None):
|
||||
"""
|
||||
Filter playlists by checking if any of the keywords are in the playlist name
|
||||
|
||||
Args:
|
||||
playlists: List of playlist objects
|
||||
keywords: List of keywords to filter by
|
||||
debug_info: Optional debug info dictionary to update with filtering stats
|
||||
|
||||
Returns:
|
||||
Filtered list of playlists
|
||||
"""
|
||||
filtered = []
|
||||
keywords_lower = [k.lower() for k in keywords]
|
||||
|
||||
for playlist in playlists:
|
||||
name = playlist.get('name', '').lower()
|
||||
|
||||
# Check if any keyword is in the playlist name
|
||||
if any(keyword in name for keyword in keywords_lower):
|
||||
filtered.append(playlist)
|
||||
|
||||
# Add to debug info if provided
|
||||
if debug_info is not None and 'matched_keywords' in debug_info:
|
||||
matched = [k for k in keywords_lower if k in name]
|
||||
for keyword in matched:
|
||||
if keyword not in debug_info['matched_keywords']:
|
||||
debug_info['matched_keywords'][keyword] = 0
|
||||
debug_info['matched_keywords'][keyword] += 1
|
||||
|
||||
return filtered
|
||||
|
||||
@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('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')
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
# Get filter keywords from the query string (default to empty list)
|
||||
filter_keywords = request.args.get('filter', '').split(',')
|
||||
filter_keywords = [k.strip() for k in filter_keywords if k.strip()]
|
||||
|
||||
# List of official Spotify user accounts to fetch playlists from
|
||||
spotify_accounts = [
|
||||
'spotify',
|
||||
'spotifycharts',
|
||||
'spotifymaps',
|
||||
'spotifyuk',
|
||||
'spotifyusa',
|
||||
'spotify_germany'
|
||||
]
|
||||
|
||||
# Get selected account from query string or default to all
|
||||
selected_account = request.args.get('account', 'all')
|
||||
|
||||
# Get debug mode parameter
|
||||
debug_mode = request.args.get('debug', 'false').lower() == 'true'
|
||||
|
||||
# Prepare debug info
|
||||
debug_info = {
|
||||
'accounts': {},
|
||||
'total_fetched': 0,
|
||||
'total_filtered': 0,
|
||||
'filtered_out': 0,
|
||||
'matched_keywords': {},
|
||||
'query_time_ms': 0,
|
||||
'duplicates_removed': 0
|
||||
}
|
||||
|
||||
# Initialize playlists list
|
||||
all_playlists = []
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# Process each Spotify account or just the selected one
|
||||
accounts_to_process = [selected_account] if selected_account != 'all' else spotify_accounts
|
||||
|
||||
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()
|
||||
for playlist in all_playlists:
|
||||
if playlist['id'] not in seen_ids:
|
||||
seen_ids.add(playlist['id'])
|
||||
unique_playlists.append(playlist)
|
||||
else:
|
||||
debug_info['duplicates_removed'] += 1
|
||||
|
||||
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)
|
||||
|
||||
# Log summary
|
||||
current_app.logger.info(
|
||||
f"Search summary: Fetched {debug_info['total_fetched']} playlists, "
|
||||
f"filtered to {debug_info['total_filtered']} playlists "
|
||||
f"({debug_info['filtered_out']} filtered out, {debug_info['duplicates_removed']} duplicates removed) "
|
||||
f"in {debug_info['query_time_ms']}ms"
|
||||
)
|
||||
|
||||
# Sort playlists by follower count or name if available
|
||||
all_playlists.sort(key=lambda x: x.get('name', '').lower())
|
||||
|
||||
# Randomize order if no specific sorting
|
||||
if not filter_keywords:
|
||||
random.shuffle(all_playlists)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching official playlists: {e}")
|
||||
flash('Error retrieving playlists from Spotify', 'danger')
|
||||
all_playlists = []
|
||||
|
||||
# Handle empty result
|
||||
if not all_playlists:
|
||||
flash('No Spotify playlists found matching your criteria', 'warning')
|
||||
|
||||
return render_template(
|
||||
'import_official_playlists.html',
|
||||
playlists=all_playlists,
|
||||
filter_keywords=filter_keywords,
|
||||
selected_account=selected_account,
|
||||
spotify_accounts=spotify_accounts,
|
||||
debug_info=debug_info,
|
||||
debug_mode=debug_mode
|
||||
)
|
||||
@@ -0,0 +1,745 @@
|
||||
"""
|
||||
Import routes for the Music Round application
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
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
|
||||
from musicround.helpers.import_helper import ImportHelper
|
||||
|
||||
import_bp = Blueprint('import', __name__, url_prefix='/import')
|
||||
|
||||
def fetch_all_user_playlists(sp, user_id, limit=50):
|
||||
"""
|
||||
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)
|
||||
|
||||
Returns:
|
||||
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}'")
|
||||
|
||||
# Hard limit to prevent infinite loops (should never be needed if API works correctly)
|
||||
max_loops = 100
|
||||
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)
|
||||
|
||||
# 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")
|
||||
|
||||
# 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}")
|
||||
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})")
|
||||
break
|
||||
|
||||
# Update offset for next batch
|
||||
offset += batch_count
|
||||
|
||||
# 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}")
|
||||
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")
|
||||
|
||||
return all_playlists
|
||||
|
||||
def filter_playlists_by_keywords(playlists, keywords, debug_info=None):
|
||||
"""
|
||||
Filter playlists by checking if any of the keywords are in the playlist name
|
||||
|
||||
Args:
|
||||
playlists: List of playlist objects
|
||||
keywords: List of keywords to filter by
|
||||
debug_info: Optional debug info dictionary to update with filtering stats
|
||||
|
||||
Returns:
|
||||
Filtered list of playlists
|
||||
"""
|
||||
filtered = []
|
||||
keywords_lower = [k.lower() for k in keywords]
|
||||
|
||||
for playlist in playlists:
|
||||
name = playlist.get('name', '').lower()
|
||||
|
||||
# Check if any keyword is in the playlist name
|
||||
if any(keyword in name for keyword in keywords_lower):
|
||||
filtered.append(playlist)
|
||||
|
||||
# Add to debug info if provided
|
||||
if debug_info is not None and 'matched_keywords' in debug_info:
|
||||
matched = [k for k in keywords_lower if k in name]
|
||||
for keyword in matched:
|
||||
if keyword not in debug_info['matched_keywords']:
|
||||
debug_info['matched_keywords'][keyword] = 0
|
||||
debug_info['matched_keywords'][keyword] += 1
|
||||
|
||||
return filtered
|
||||
|
||||
@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']
|
||||
|
||||
# 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:
|
||||
flash(f'Successfully imported {result["imported_count"]} songs from official Spotify 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')
|
||||
else:
|
||||
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
# Get filter keywords from the query string (default to empty list)
|
||||
filter_keywords = request.args.get('filter', '').split(',')
|
||||
filter_keywords = [k.strip() for k in filter_keywords if k.strip()]
|
||||
|
||||
# List of official Spotify user accounts to fetch playlists from
|
||||
spotify_accounts = [
|
||||
'spotify',
|
||||
'spotifycharts',
|
||||
'spotifymaps',
|
||||
'spotifyuk',
|
||||
'spotifyusa',
|
||||
'spotify_germany'
|
||||
]
|
||||
|
||||
# Get selected account from query string or default to all
|
||||
selected_account = request.args.get('account', 'all')
|
||||
|
||||
# Get debug mode parameter
|
||||
debug_mode = request.args.get('debug', 'false').lower() == 'true'
|
||||
|
||||
# Prepare debug info
|
||||
debug_info = {
|
||||
'accounts': {},
|
||||
'total_fetched': 0,
|
||||
'total_filtered': 0,
|
||||
'filtered_out': 0,
|
||||
'matched_keywords': {},
|
||||
'query_time_ms': 0,
|
||||
'duplicates_removed': 0
|
||||
}
|
||||
|
||||
# Initialize playlists list
|
||||
all_playlists = []
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# Process each Spotify account or just the selected one
|
||||
accounts_to_process = [selected_account] if selected_account != 'all' else spotify_accounts
|
||||
|
||||
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()
|
||||
for playlist in all_playlists:
|
||||
if playlist['id'] not in seen_ids:
|
||||
seen_ids.add(playlist['id'])
|
||||
unique_playlists.append(playlist)
|
||||
else:
|
||||
debug_info['duplicates_removed'] += 1
|
||||
|
||||
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)
|
||||
|
||||
# Log summary
|
||||
current_app.logger.info(
|
||||
f"Search summary: Fetched {debug_info['total_fetched']} playlists, "
|
||||
f"filtered to {debug_info['total_filtered']} playlists "
|
||||
f"({debug_info['filtered_out']} filtered out, {debug_info['duplicates_removed']} duplicates removed) "
|
||||
f"in {debug_info['query_time_ms']}ms"
|
||||
)
|
||||
|
||||
# Sort playlists by follower count or name if available
|
||||
all_playlists.sort(key=lambda x: x.get('name', '').lower())
|
||||
|
||||
# Randomize order if no specific sorting
|
||||
if not filter_keywords:
|
||||
random.shuffle(all_playlists)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching official playlists: {e}")
|
||||
flash('Error retrieving playlists from Spotify', 'danger')
|
||||
all_playlists = []
|
||||
|
||||
# Handle empty result
|
||||
if not all_playlists:
|
||||
flash('No Spotify playlists found matching your criteria', 'warning')
|
||||
|
||||
# Get the bearer token from the session to display in the form
|
||||
session_bearer_token = session.get('direct_bearer_token', '')
|
||||
spotify_username = session.get('direct_spotify_username')
|
||||
|
||||
return render_template(
|
||||
'import_official_playlists.html',
|
||||
playlists=all_playlists,
|
||||
filter_keywords=filter_keywords,
|
||||
selected_account=selected_account,
|
||||
spotify_accounts=spotify_accounts,
|
||||
debug_info=debug_info,
|
||||
debug_mode=debug_mode,
|
||||
session_bearer_token=session_bearer_token,
|
||||
spotify_username=spotify_username,
|
||||
direct_mode=False
|
||||
)
|
||||
|
||||
@import_bp.route('/direct-official-playlists', methods=['GET', 'POST'])
|
||||
def direct_official_playlists():
|
||||
"""Display and import official Spotify playlists using the direct client with bearer token"""
|
||||
# Check if user has provided a bearer token
|
||||
bearer_token = session.get('direct_bearer_token')
|
||||
if not bearer_token:
|
||||
flash('Please provide a bearer token first', 'warning')
|
||||
return redirect(url_for('import.direct_spotify_auth'))
|
||||
|
||||
# Initialize direct Spotify client with bearer token
|
||||
from musicround.helpers.spotify_direct import SpotifyDirectClient
|
||||
direct_client = SpotifyDirectClient(bearer_token=bearer_token)
|
||||
|
||||
# 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:
|
||||
flash(f'Successfully imported {result["imported_count"]} songs from official Spotify 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')
|
||||
else:
|
||||
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
# Get filter keywords from the query string (default to empty list)
|
||||
filter_keywords = request.args.get('filter', '').split(',')
|
||||
filter_keywords = [k.strip() for k in filter_keywords if k.strip()]
|
||||
|
||||
# List of official Spotify user accounts to fetch playlists from
|
||||
spotify_accounts = [
|
||||
'spotify',
|
||||
'spotifycharts',
|
||||
'spotifymaps',
|
||||
'spotifyuk',
|
||||
'spotifyusa',
|
||||
'spotify_germany'
|
||||
]
|
||||
|
||||
# Get selected account from query string or default to all
|
||||
selected_account = request.args.get('account', 'all')
|
||||
|
||||
# Get debug mode parameter
|
||||
debug_mode = request.args.get('debug', 'false').lower() == 'true'
|
||||
|
||||
# Prepare debug info
|
||||
debug_info = {
|
||||
'accounts': {},
|
||||
'total_fetched': 0,
|
||||
'total_filtered': 0,
|
||||
'filtered_out': 0,
|
||||
'matched_keywords': {},
|
||||
'query_time_ms': 0,
|
||||
'duplicates_removed': 0
|
||||
}
|
||||
|
||||
# Initialize playlists list
|
||||
all_playlists = []
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
|
||||
# Process each Spotify account or just the selected one
|
||||
accounts_to_process = [selected_account] if selected_account != 'all' else spotify_accounts
|
||||
|
||||
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 using direct client
|
||||
account_playlists = direct_client.fetch_all_user_playlists(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()
|
||||
for playlist in all_playlists:
|
||||
if playlist['id'] not in seen_ids:
|
||||
seen_ids.add(playlist['id'])
|
||||
unique_playlists.append(playlist)
|
||||
else:
|
||||
debug_info['duplicates_removed'] += 1
|
||||
|
||||
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)
|
||||
|
||||
# Log summary
|
||||
current_app.logger.info(
|
||||
f"Direct search summary: Fetched {debug_info['total_fetched']} playlists, "
|
||||
f"filtered to {debug_info['total_filtered']} playlists "
|
||||
f"({debug_info['filtered_out']} filtered out, {debug_info['duplicates_removed']} duplicates removed) "
|
||||
f"in {debug_info['query_time_ms']}ms"
|
||||
)
|
||||
|
||||
# Sort playlists by follower count or name if available
|
||||
all_playlists.sort(key=lambda x: x.get('name', '').lower())
|
||||
|
||||
# Randomize order if no specific sorting
|
||||
if not filter_keywords:
|
||||
random.shuffle(all_playlists)
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error fetching official playlists with direct client: {e}")
|
||||
import traceback
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
flash(f'Error retrieving playlists from Spotify: {str(e)}', 'danger')
|
||||
all_playlists = []
|
||||
|
||||
# Handle empty result
|
||||
if not all_playlists:
|
||||
flash('No Spotify playlists found matching your criteria', 'warning')
|
||||
|
||||
return render_template(
|
||||
'import_official_playlists.html',
|
||||
playlists=all_playlists,
|
||||
filter_keywords=filter_keywords,
|
||||
selected_account=selected_account,
|
||||
spotify_accounts=spotify_accounts,
|
||||
debug_info=debug_info,
|
||||
debug_mode=debug_mode,
|
||||
direct_mode=True,
|
||||
spotify_username=session.get('direct_spotify_username')
|
||||
)
|
||||
|
||||
@import_bp.route('/test-spotify-client', methods=['GET'])
|
||||
def test_spotify_client():
|
||||
"""Test route to compare different Spotify client implementations"""
|
||||
if 'access_token' not in session:
|
||||
return redirect(url_for('users.login'))
|
||||
|
||||
# Get Spotify account to check from query parameters
|
||||
account = request.args.get('account', 'spotify')
|
||||
|
||||
# Results container
|
||||
results = {
|
||||
'spotipy': {
|
||||
'playlists': [],
|
||||
'count': 0,
|
||||
'total': 0,
|
||||
'time_ms': 0,
|
||||
'error': None
|
||||
},
|
||||
'direct': {
|
||||
'playlists': [],
|
||||
'count': 0,
|
||||
'total': 0,
|
||||
'time_ms': 0,
|
||||
'error': None
|
||||
}
|
||||
}
|
||||
|
||||
# Test spotipy implementation
|
||||
try:
|
||||
sp = current_app.config['sp']
|
||||
start_time = time.time()
|
||||
|
||||
current_app.logger.info(f"Testing spotipy implementation for account {account}")
|
||||
spotipy_playlists = fetch_all_user_playlists(sp, account)
|
||||
|
||||
end_time = time.time()
|
||||
duration_ms = int((end_time - start_time) * 1000)
|
||||
|
||||
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}")
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
results['spotipy']['error'] = str(e)
|
||||
|
||||
# Test direct implementation
|
||||
try:
|
||||
from musicround.helpers.spotify_direct import SpotifyDirectClient
|
||||
|
||||
# Get bearer token from session if available
|
||||
bearer_token = session.get('direct_bearer_token')
|
||||
if not bearer_token:
|
||||
current_app.logger.warning("No bearer token in session for direct client")
|
||||
results['direct']['error'] = "No bearer token available. Please set a token in Direct Auth page first."
|
||||
else:
|
||||
direct_client = SpotifyDirectClient(bearer_token=bearer_token)
|
||||
|
||||
start_time = time.time()
|
||||
current_app.logger.info(f"Testing direct implementation for account {account}")
|
||||
direct_playlists = direct_client.fetch_all_user_playlists(account)
|
||||
|
||||
end_time = time.time()
|
||||
duration_ms = int((end_time - start_time) * 1000)
|
||||
|
||||
results['direct']['playlists'] = direct_playlists
|
||||
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}")
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
results['direct']['error'] = str(e)
|
||||
|
||||
# Compare playlists between implementations
|
||||
comparison = {
|
||||
'only_in_spotipy': [],
|
||||
'only_in_direct': [],
|
||||
'in_both': []
|
||||
}
|
||||
|
||||
if results['spotipy']['playlists'] and results['direct']['playlists']:
|
||||
spotipy_ids = {pl['id'] for pl in results['spotipy']['playlists']}
|
||||
direct_ids = {pl['id'] for pl in results['direct']['playlists']}
|
||||
|
||||
comparison['only_in_spotipy'] = list(spotipy_ids - direct_ids)
|
||||
comparison['only_in_direct'] = list(direct_ids - spotipy_ids)
|
||||
comparison['in_both'] = list(spotipy_ids.intersection(direct_ids))
|
||||
|
||||
# Add direct auth link to template data
|
||||
direct_auth_url = url_for('import.direct_spotify_auth')
|
||||
|
||||
# Render the comparison template
|
||||
return render_template(
|
||||
'spotify_client_test.html',
|
||||
account=account,
|
||||
results=results,
|
||||
comparison=comparison,
|
||||
direct_auth_url=direct_auth_url,
|
||||
has_bearer_token=bool(session.get('direct_bearer_token'))
|
||||
)
|
||||
|
||||
@import_bp.route('/raw-playlists', methods=['GET'])
|
||||
def get_raw_playlists():
|
||||
"""
|
||||
Get raw playlists from Spotify without any pagination logic.
|
||||
This helps diagnose issues with the playlist retrieval.
|
||||
"""
|
||||
if 'access_token' not in session:
|
||||
return redirect(url_for('users.login'))
|
||||
|
||||
# Get Spotify account to check
|
||||
account = request.args.get('account', 'spotify')
|
||||
# Get limit parameter (max 50)
|
||||
limit = min(int(request.args.get('limit', '50')), 50)
|
||||
# Get offset parameter
|
||||
offset = int(request.args.get('offset', '0'))
|
||||
|
||||
results = {
|
||||
'spotipy': {
|
||||
'raw_response': None,
|
||||
'error': None
|
||||
},
|
||||
'direct': {
|
||||
'raw_response': None,
|
||||
'error': None
|
||||
}
|
||||
}
|
||||
|
||||
# Test spotipy raw response
|
||||
try:
|
||||
sp = current_app.config['sp']
|
||||
current_app.logger.info(f"Getting raw playlists with spotipy for {account}, limit={limit}, offset={offset}")
|
||||
raw_result = sp.user_playlists(account, limit=limit, offset=offset)
|
||||
results['spotipy']['raw_response'] = raw_result
|
||||
except Exception as e:
|
||||
import traceback
|
||||
current_app.logger.error(f"Error getting raw spotipy playlists: {e}")
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
results['spotipy']['error'] = str(e)
|
||||
|
||||
# Test direct API raw response
|
||||
try:
|
||||
from musicround.helpers.spotify_direct import SpotifyDirectClient
|
||||
|
||||
# Get bearer token from session if available
|
||||
bearer_token = session.get('direct_bearer_token')
|
||||
if not bearer_token:
|
||||
current_app.logger.warning("No bearer token in session for direct client")
|
||||
results['direct']['error'] = "No bearer token available. Please set a token in Direct Auth page first."
|
||||
else:
|
||||
direct_client = SpotifyDirectClient(bearer_token=bearer_token)
|
||||
current_app.logger.info(f"Getting raw playlists with direct API for {account}, limit={limit}, offset={offset}")
|
||||
raw_result = direct_client.user_playlists(account, limit=limit, offset=offset)
|
||||
results['direct']['raw_response'] = raw_result
|
||||
except Exception as e:
|
||||
import traceback
|
||||
current_app.logger.error(f"Error getting raw direct playlists: {e}")
|
||||
current_app.logger.error(traceback.format_exc())
|
||||
results['direct']['error'] = str(e)
|
||||
|
||||
# Add direct auth link to template data
|
||||
direct_auth_url = url_for('import.direct_spotify_auth')
|
||||
|
||||
return render_template(
|
||||
'raw_playlists.html',
|
||||
account=account,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
results=results,
|
||||
direct_auth_url=direct_auth_url,
|
||||
has_bearer_token=bool(session.get('direct_bearer_token'))
|
||||
)
|
||||
|
||||
@import_bp.route('/direct-auth', methods=['GET', 'POST'])
|
||||
def direct_spotify_auth():
|
||||
"""
|
||||
Allow users to manually enter a Spotify bearer token for direct API access.
|
||||
This bypasses the OAuth flow and is useful when API limitations are in place.
|
||||
"""
|
||||
error = None
|
||||
success = None
|
||||
|
||||
if request.method == 'POST':
|
||||
bearer_token = request.form.get('bearer_token')
|
||||
if bearer_token:
|
||||
try:
|
||||
# Store the token in session
|
||||
session['direct_bearer_token'] = bearer_token
|
||||
|
||||
# Test the token with a simple request
|
||||
from musicround.helpers.spotify_direct import SpotifyDirectClient
|
||||
client = SpotifyDirectClient(bearer_token=bearer_token)
|
||||
|
||||
# Try to get current user info as a test
|
||||
result = client._make_api_request("me")
|
||||
|
||||
if result and 'id' in result:
|
||||
session['direct_spotify_user'] = result['id']
|
||||
session['direct_spotify_username'] = result.get('display_name', result['id'])
|
||||
success = f"Successfully authenticated as {session['direct_spotify_username']}"
|
||||
else:
|
||||
error = "Token validation failed. Please check the token and try again."
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error validating bearer token: {e}")
|
||||
error = f"Error: {str(e)}"
|
||||
else:
|
||||
error = "No bearer token provided"
|
||||
|
||||
# Get stored user info if available
|
||||
spotify_user = session.get('direct_spotify_user')
|
||||
spotify_username = session.get('direct_spotify_username')
|
||||
|
||||
return render_template(
|
||||
'spotify_direct_auth.html',
|
||||
error=error,
|
||||
success=success,
|
||||
spotify_user=spotify_user,
|
||||
spotify_username=spotify_username
|
||||
)
|
||||
|
||||
@import_bp.route('/update-direct-token', methods=['POST'])
|
||||
def update_direct_token():
|
||||
"""Update the direct bearer token and redirect back to the referring page"""
|
||||
# Get return URL from form or default to playlist page
|
||||
return_url = request.form.get('return_url') or url_for('import.direct_official_playlists')
|
||||
|
||||
# Check if clearing token was requested
|
||||
if request.form.get('clear_token'):
|
||||
session.pop('direct_bearer_token', None)
|
||||
session.pop('direct_spotify_user', None)
|
||||
session.pop('direct_spotify_username', None)
|
||||
flash('Bearer token cleared successfully', 'success')
|
||||
return redirect(return_url)
|
||||
|
||||
# Get bearer token from form
|
||||
bearer_token = request.form.get('bearer_token')
|
||||
if not bearer_token:
|
||||
flash('No bearer token provided', 'warning')
|
||||
return redirect(return_url)
|
||||
|
||||
try:
|
||||
# Store the token in session
|
||||
session['direct_bearer_token'] = bearer_token
|
||||
|
||||
# Test the token with a simple request
|
||||
from musicround.helpers.spotify_direct import SpotifyDirectClient
|
||||
client = SpotifyDirectClient(bearer_token=bearer_token)
|
||||
|
||||
# Try to get current user info as a test
|
||||
result = client._make_api_request("me")
|
||||
|
||||
if result and 'id' in result:
|
||||
session['direct_spotify_user'] = result['id']
|
||||
session['direct_spotify_username'] = result.get('display_name', result['id'])
|
||||
flash(f'Successfully authenticated as {session["direct_spotify_username"]}', 'success')
|
||||
else:
|
||||
flash('Token validation failed. Please check the token and try again.', 'error')
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Error validating bearer token: {e}")
|
||||
flash(f'Error validating token: {str(e)}', 'error')
|
||||
|
||||
return redirect(return_url)
|
||||
@@ -0,0 +1,111 @@
|
||||
import random
|
||||
import os
|
||||
import requests
|
||||
import json
|
||||
from flask import Blueprint, session, redirect, request, render_template, url_for, current_app, flash
|
||||
from musicround.models import Song, db
|
||||
from musicround.helpers.metadata import get_song_metadata_by_isrc
|
||||
from musicround.helpers.import_helper import ImportHelper
|
||||
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
|
||||
@import_songs_bp.route('/song', methods=['GET', 'POST'])
|
||||
def import_song():
|
||||
if 'access_token' not in session:
|
||||
return redirect(url_for('users.login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
track_id = request.form['song_id']
|
||||
result = ImportHelper.import_item('spotify', 'track', track_id)
|
||||
|
||||
if result['imported_count'] > 0:
|
||||
flash(f'Successfully imported {result["imported_count"]} song!', 'success')
|
||||
elif result['skipped_count'] > 0:
|
||||
flash('Song was already in the database.', 'info')
|
||||
else:
|
||||
flash(f'Error importing song: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
return render_template('service_import.html',
|
||||
service_name='Spotify',
|
||||
item_type='Track',
|
||||
url_example_prefix='https://open.spotify.com/track/',
|
||||
url_example_id='6rqhFgbbKwnb9MLmUQDhG6',
|
||||
id_field='song_id',
|
||||
form_action=url_for('import_songs.import_song'),
|
||||
back_url=url_for('core.search'))
|
||||
|
||||
@import_songs_bp.route('/playlist', methods=['GET', 'POST'])
|
||||
def import_playlist():
|
||||
if 'access_token' not in session:
|
||||
return redirect(url_for('users.login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
playlist_id = request.form['playlist_id']
|
||||
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'Encountered {result["error_count"]} errors during import.', 'warning')
|
||||
else:
|
||||
flash(f'Error importing playlist: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
return render_template('service_import.html',
|
||||
service_name='Spotify',
|
||||
item_type='Playlist',
|
||||
url_example_prefix='https://open.spotify.com/playlist/',
|
||||
url_example_id='37i9dQZF1DXcBWIGoYBM5M',
|
||||
id_field='playlist_id',
|
||||
form_action=url_for('import_songs.import_playlist'),
|
||||
back_url=url_for('core.search'))
|
||||
|
||||
@import_songs_bp.route('/album', methods=['GET', 'POST'])
|
||||
def import_album():
|
||||
if 'access_token' not in session:
|
||||
return redirect(url_for('users.login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
album_id = request.form['album_id']
|
||||
result = ImportHelper.import_item('spotify', 'album', album_id)
|
||||
|
||||
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')
|
||||
else:
|
||||
flash(f'Error importing album: {", ".join(result["errors"])}', 'danger')
|
||||
|
||||
return redirect(url_for('core.view_songs'))
|
||||
|
||||
return render_template('service_import.html',
|
||||
service_name='Spotify',
|
||||
item_type='Album',
|
||||
url_example_prefix='https://open.spotify.com/album/',
|
||||
url_example_id='4aawyAB9vmqN3uQ7FjRGTy',
|
||||
id_field='album_id',
|
||||
form_action=url_for('import_songs.import_album'),
|
||||
back_url=url_for('core.search'))
|
||||
@@ -0,0 +1,21 @@
|
||||
from flask import Blueprint, session, redirect, url_for, jsonify, request, current_app
|
||||
import base64
|
||||
|
||||
process_bp = Blueprint('process', __name__, url_prefix='/process')
|
||||
|
||||
@process_bp.route('/base64', methods=['POST'])
|
||||
def base64_encode_data():
|
||||
"""
|
||||
Return base64-encoded string from data provided in request body.
|
||||
"""
|
||||
if 'access_token' not in session:
|
||||
return redirect(url_for('users.login')) # Assuming 'users.login' is the correct endpoint
|
||||
|
||||
# Get binary data from request
|
||||
data = request.get_data()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
return jsonify({
|
||||
'encoded': base64.b64encode(data).decode('utf-8')
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user