Files
gh-christianlouis-quizzical…/musicround/routes/generate.py
T
Christian Krakau-Louis bc4055ded5 Fixed Spotify stuff
2025-06-09 13:13:30 +02:00

576 lines
22 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
Always returns the songs in the playlist, even if all already exist in the DB.
"""
try:
songs_per_round = current_app.config.get('SONGS_PER_ROUND', 10)
import_result = ImportHelper.import_item(
item_id=playlist_id,
item_type='playlist',
service_name='spotify',
oauth_spotify=oauth.spotify
)
from musicround.models import Song
# If we have imported_song_ids, use them (these are DB IDs)
if import_result.get('imported_song_ids'):
song_db_ids = import_result['imported_song_ids']
imported_songs = Song.query.filter(Song.id.in_(song_db_ids)).all()
return imported_songs[:songs_per_round]
# If no imported_song_ids, fetch all Spotify IDs from the playlist and get those songs from DB
# Use the Spotify API directly to get the playlist track IDs
sp = oauth.spotify
# Get playlist tracks (paginated)
all_spotify_ids = []
next_url = f'playlists/{playlist_id}/tracks'
authlib_token = {
'access_token': current_user.spotify_token,
'refresh_token': current_user.spotify_refresh_token,
'token_type': 'Bearer',
'expires_at': int(current_user.spotify_token_expiry.timestamp()) if current_user.spotify_token_expiry else None
}
while next_url:
resp = sp.get(next_url, token=authlib_token)
resp.raise_for_status()
data = resp.json()
for item in data.get('items', []):
track = item.get('track')
if track and track.get('id'):
all_spotify_ids.append(track['id'])
next_url = data.get('next')
# If next_url is a full URL, convert to relative for sp.get
if next_url and next_url.startswith('https://api.spotify.com/v1/'):
next_url = next_url.replace('https://api.spotify.com/v1/', '')
if not all_spotify_ids:
current_app.logger.warning(f"No valid tracks found in Spotify playlist {playlist_id}")
return []
# Query all songs in DB with those Spotify IDs, preserving playlist order
songs_by_spotify_id = {s.spotify_id: s for s in Song.query.filter(Song.spotify_id.in_(all_spotify_ids)).all()}
ordered_songs = [songs_by_spotify_id[sid] for sid in all_spotify_ids if sid in songs_by_spotify_id]
return ordered_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'))