2f55f898ed
- Implemented Spotify OAuth handling in spotify_client_manager.py to ensure valid access tokens for users. - Created helper functions in spotify_helper.py for refreshing tokens and retrieving user information. - Developed manage_spotify.html template for connecting and managing Spotify accounts, displaying connection status and token information. - Added logging for token management processes to enhance debugging and monitoring. - Introduced a debug client for Spotify interactions to facilitate easier testing and development.
542 lines
20 KiB
Python
542 lines
20 KiB
Python
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
|
|
from musicround.helpers.auth_helpers import oauth
|
|
from musicround.helpers.import_helper import ImportHelper
|
|
|
|
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)
|
|
|
|
imported_songs = ImportHelper.import_item(
|
|
item_id=playlist_id,
|
|
item_type='playlist',
|
|
source='deezer',
|
|
deezer_client=deezer_client
|
|
)
|
|
|
|
if not imported_songs:
|
|
current_app.logger.warning(f"No songs returned from ImportHelper.import_item for Deezer playlist {playlist_id}")
|
|
return []
|
|
|
|
return imported_songs[:songs_per_round]
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error fetching or importing Deezer playlist {playlist_id}: {e}")
|
|
import traceback
|
|
current_app.logger.error(traceback.format_exc())
|
|
return []
|
|
|
|
def get_songs_from_spotify_playlist(playlist_id):
|
|
"""
|
|
Fetch songs from a Spotify playlist, properly import them with metadata, and return them
|
|
"""
|
|
try:
|
|
songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10)
|
|
|
|
imported_songs = ImportHelper.import_item(
|
|
item_id=playlist_id,
|
|
item_type='playlist',
|
|
source='spotify',
|
|
oauth_spotify=oauth.spotify
|
|
)
|
|
|
|
if not imported_songs:
|
|
current_app.logger.warning(f"No songs returned from ImportHelper.import_item for Spotify playlist {playlist_id}")
|
|
return []
|
|
|
|
return imported_songs[:songs_per_round]
|
|
except Exception as e:
|
|
current_app.logger.error(f"Error fetching or importing Spotify playlist {playlist_id}: {e}")
|
|
import traceback
|
|
current_app.logger.error(traceback.format_exc())
|
|
return []
|
|
|
|
@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')) |