Files
gh-christianlouis-quizzical…/musicround/routes/rounds.py
T
Christian Krakau-Louis 2f55f898ed Add Spotify integration with improved token management and user interface
- Implemented Spotify OAuth handling in spotify_client_manager.py to ensure valid access tokens for users.
- Created helper functions in spotify_helper.py for refreshing tokens and retrieving user information.
- Developed manage_spotify.html template for connecting and managing Spotify accounts, displaying connection status and token information.
- Added logging for token management processes to enhance debugging and monitoring.
- Introduced a debug client for Spotify interactions to facilitate easier testing and development.
2025-05-27 21:40:37 +02:00

1115 lines
45 KiB
Python

import os
import smtplib
import shutil
import base64
import tempfile
import requests
import logging
from datetime import datetime
from io import BytesIO
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
import re
import zipfile
import io
import json
from flask import Blueprint, session, redirect, request, render_template, url_for, current_app, send_file, jsonify, flash
from flask_login import current_user, login_required
from musicround.models import Round, Song, db
from pydub import AudioSegment
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from musicround.helpers.auth_helpers import oauth
rounds_bp = Blueprint('rounds', __name__, url_prefix='/rounds')
@rounds_bp.route('/')
@login_required
def rounds_list():
"""Display a list of all rounds"""
rounds = Round.query.all()
return render_template('rounds.html', rounds=rounds)
@rounds_bp.route('/<int:round_id>')
@login_required
def round_detail(round_id):
"""Display details of a specific round"""
rnd = Round.query.get(round_id)
if rnd:
song_ids = rnd.songs.split(',')
songs = Song.query.filter(Song.id.in_(song_ids)).all()
# Ensure songs are in the same order as in the round's song list
song_id_to_obj = {str(song.id): song for song in songs}
ordered_songs = [song_id_to_obj.get(song_id) for song_id in song_ids if song_id in song_id_to_obj]
email_error = session.pop('email_error', None) # Retrieve and remove the error message from the session
# For oauth.spotify, we need to ensure we have a valid token if needed for this view
user_info = None
try:
if current_user.is_authenticated and current_user.spotify_token:
# Use oauth.spotify to get user info
# Ensure the token is fresh or handle potential MissingTokenError
user_info_response = oauth.spotify.get('https://api.spotify.com/v1/me')
user_info_response.raise_for_status() # Raise an exception for bad status codes
user_info = user_info_response.json()
except Exception as e: # Catch a broader range of exceptions, including MissingTokenError
current_app.logger.warning(f"Could not get Spotify user info using Authlib: {str(e)}")
return render_template('round_detail.html', round=rnd, songs=ordered_songs, user_info=user_info, email_error=email_error)
else:
return 'Round not found'
@rounds_bp.route('/<int:round_id>/update-name', methods=['POST'])
@login_required
def update_round_name(round_id):
"""Update the name of a round"""
rnd = Round.query.get_or_404(round_id)
round_name = request.form.get('round_name', '').strip()
# Update the round name
rnd.name = round_name if round_name else None
db.session.commit()
flash('Round name updated successfully', 'success')
return redirect(url_for('rounds.round_detail', round_id=round_id))
@rounds_bp.route('/<int:round_id>/update-songs', methods=['POST'])
@login_required
def update_round_songs(round_id):
"""Update the songs in a round (order, additions, removals)"""
rnd = Round.query.get_or_404(round_id)
song_order = request.form.get('song_order', '')
if song_order:
# Only reset the flags if the song order has actually changed
if rnd.songs != song_order:
rnd.songs = song_order
# Reset the MP3 and PDF generated flags when the song order changes
rnd.reset_generated_status()
db.session.commit()
flash('Round songs updated successfully', 'success')
else:
flash('No changes to save', 'info')
else:
flash('No song order provided', 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
@rounds_bp.route('/round/<int:round_id>/mp3', methods=['POST'])
@login_required
def round_mp3(round_id):
"""Generates an MP3 file for a given round with intro, outro, and number announcements."""
from musicround.helpers.utils import get_mp3_path
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
# For AJAX requests, keep the response consistent
if not current_user.is_authenticated:
return jsonify({'success': False, 'error': 'Authentication required'}), 401
round = Round.query.get_or_404(round_id)
song_ids = [int(song_id) for song_id in round.songs.split(',')]
# Create a dict mapping song_id to Song object for all songs in the round
songs_dict = {song.id: song for song in Song.query.filter(Song.id.in_(song_ids)).all()}
# Preserve the exact ordering from the round.songs field
songs = [songs_dict.get(song_id) for song_id in song_ids if songs_dict.get(song_id)]
# Create a directory for rounds in /data if it doesn't exist
rounds_dir = '/data/rounds'
if not os.path.exists(rounds_dir):
os.makedirs(rounds_dir)
# Define the path for the MP3 file
mp3_file_path = os.path.join(rounds_dir, f'round_{round_id}.mp3')
current_app.logger.info(f"Checking MP3 generation status for round {round_id}")
# Check if the MP3 has already been generated and if the file exists
# We'll generate a new file if either the flag is False or the file doesn't exist
if round.mp3_generated and os.path.exists(mp3_file_path):
current_app.logger.info(f"MP3 file already exists and is up to date at: {mp3_file_path}")
download_url = url_for('rounds.download_mp3', round_id=round_id)
# Check if this is an AJAX request
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': True,
'message': 'MP3 file already exists',
'download_url': download_url
})
else:
# Traditional form submission, send file
return send_file(mp3_file_path, as_attachment=True)
# Load intro, outro, and replay audio segments - using user's custom ones if available
try:
# Use get_mp3_path helper to get the appropriate path for each MP3 type
intro_path = get_mp3_path(current_user, 'intro')
outro_path = get_mp3_path(current_user, 'outro')
replay_path = get_mp3_path(current_user, 'replay')
intro = AudioSegment.from_mp3(intro_path)
outro = AudioSegment.from_mp3(outro_path)
replay = AudioSegment.from_mp3(replay_path)
current_app.logger.info(f"Using MP3 files - Intro: {intro_path}, Outro: {outro_path}, Replay: {replay_path}")
except Exception as e:
error_msg = f"Error loading intro/outro/replay audio: {e}"
current_app.logger.error(error_msg)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': error_msg})
else:
flash(error_msg, 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
# Create an empty audio segment
combined_audio = AudioSegment.empty()
combined_audio += intro
# Create temporary directory for number announcements and song previews
with tempfile.TemporaryDirectory() as temp_dir:
# Store song audio segments for later replay
song_segments = []
number_segments = []
# First pass - append each song's preview with number announcements
for i, song in enumerate(songs):
number_audio_path = os.path.join(current_app.root_path, 'static', 'audio', f'{i+1}.mp3')
try:
number_audio = AudioSegment.from_mp3(number_audio_path)
number_segments.append(number_audio)
except Exception as e:
error_msg = f"Error loading number audio {i+1}: {e}"
current_app.logger.error(error_msg)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': error_msg})
else:
flash(error_msg, 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
if song.deezer_id:
try:
# Fetch fresh preview URL from Deezer API
deezer_client = current_app.config['deezer']
track = deezer_client.get_track(song.deezer_id)
current_app.logger.info(f"Deezer track info: {track}") # Log the track info
preview_url = track.get('preview')
if not preview_url:
current_app.logger.warning(f"No preview available for {song.title} (Deezer ID: {song.deezer_id})")
song_segments.append(None)
continue
# Download the song preview to a temporary file
response = requests.get(preview_url, stream=True)
response.raise_for_status() # Raise an exception for bad status codes
temp_song_path = os.path.join(temp_dir, f'song_{song.id}.mp3')
with open(temp_song_path, 'wb') as temp_song_file:
for chunk in response.iter_content(chunk_size=8192):
temp_song_file.write(chunk)
song_audio = AudioSegment.from_mp3(temp_song_path)
song_segments.append(song_audio)
# Add to the combined audio for first playthrough
combined_audio += number_audio
combined_audio += song_audio
except requests.exceptions.RequestException as e:
error_msg = f"Error downloading {song.title} (Deezer ID: {song.deezer_id}): {e}"
current_app.logger.error(error_msg)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': error_msg})
else:
flash(error_msg, 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
except Exception as e:
error_msg = f"Error processing {song.title} (Deezer ID: {song.deezer_id}): {e}"
current_app.logger.error(error_msg)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': error_msg})
else:
flash(error_msg, 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
else:
current_app.logger.warning(f"No Deezer ID available for {song.title}")
song_segments.append(None)
# Add the replay announcement
combined_audio += replay
# Second pass - replay all songs
for i, (song_audio, number_audio) in enumerate(zip(song_segments, number_segments)):
if song_audio is not None:
combined_audio += number_audio
combined_audio += song_audio
combined_audio += outro
# Export the combined audio to an MP3 file
try:
combined_audio.export(mp3_file_path, format="mp3")
current_app.logger.info(f"MP3 file successfully generated at: {mp3_file_path}")
# Update the round object to indicate MP3 has been generated and update timestamp
round.mp3_generated = True
round.last_generated_at = datetime.utcnow()
db.session.commit()
# Check if this is an AJAX request
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
download_url = url_for('rounds.download_mp3', round_id=round_id)
return jsonify({
'success': True,
'message': 'MP3 file successfully generated',
'download_url': download_url
})
else:
# Traditional form submission, send file
flash('MP3 generated successfully', 'success')
return send_file(mp3_file_path, as_attachment=True)
except Exception as e:
error_msg = f"Error generating MP3 file: {e}"
current_app.logger.error(error_msg)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': error_msg})
else:
flash(error_msg, 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
@rounds_bp.route('/download/mp3/round_<int:round_id>', methods=['GET'])
@login_required
def download_mp3(round_id):
"""Download an MP3 file for a round"""
mp3_file_path = os.path.join('/data/rounds', f'round_{round_id}.mp3')
if not os.path.exists(mp3_file_path):
flash('MP3 file not found. Please generate the MP3 first.', 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
return send_file(mp3_file_path, as_attachment=True)
@rounds_bp.route('/download/pdf/round_<int:round_id>', methods=['GET'])
@login_required
def download_pdf(round_id):
"""Download a PDF file for a round"""
pdf_file_path = os.path.join('/data/pdfs', f'round_{round_id}.pdf')
if not os.path.exists(pdf_file_path):
flash('PDF file not found. Please generate the PDF first.', 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
return send_file(pdf_file_path, as_attachment=True)
def generate_pdf(round_id):
"""
Creates a stylish PDF "round_{id}.pdf" that lists the songs in that round.
Returns raw PDF data as bytes, for sending or saving.
"""
rnd = Round.query.get(round_id)
if not rnd:
return 'Round not found'
file_name = f'round_{round_id}.pdf'
dir_path = '/data/pdfs' # Use /data/pdfs for storing PDFs
if not os.path.exists(dir_path):
os.makedirs(dir_path)
file_path = os.path.join(dir_path, file_name)
# If it exists and the flag indicates it's up-to-date, read from disk
if os.path.exists(file_path) and rnd.pdf_generated:
with open(file_path, 'rb') as file:
return file.read()
# Get songs data in the correct order
song_ids = [int(sid) for sid in rnd.songs.split(',')]
# Create a dict mapping song_id to Song object for all songs in the round
songs_dict = {song.id: song for song in Song.query.filter(Song.id.in_(song_ids)).all()}
# Preserve the exact ordering from the round.songs field
songs = [songs_dict.get(song_id) for song_id in song_ids if songs_dict.get(song_id)]
# Import reportlab components
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch, cm
# Create a buffer
buffer = BytesIO()
# Set up the PDF document
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=1.5*cm,
rightMargin=1.5*cm,
topMargin=1.5*cm,
bottomMargin=1.5*cm
)
# Styles
styles = getSampleStyleSheet()
# Create custom styles
title_style = ParagraphStyle(
'Title',
parent=styles['Heading1'],
fontSize=24,
textColor=colors.HexColor('#00ACC1'), # Teal color for titles
spaceAfter=24,
alignment=1 # Center alignment
)
subtitle_style = ParagraphStyle(
'Subtitle',
parent=styles['Heading2'],
fontSize=16,
textColor=colors.HexColor('#333333'),
spaceBefore=12,
spaceAfter=12
)
info_style = ParagraphStyle(
'Info',
parent=styles['Normal'],
fontSize=12,
textColor=colors.HexColor('#666666'),
spaceBefore=6,
spaceAfter=12
)
song_style = ParagraphStyle(
'Song',
parent=styles['Normal'],
fontSize=14,
spaceBefore=4,
spaceAfter=4
)
# Story (container for PDF elements)
story = []
# Add logo
logo_path = os.path.join(current_app.root_path, 'static', 'img', 'light', 'logotype.png')
if os.path.exists(logo_path):
img = Image(logo_path)
img.drawHeight = 1.2*cm
img._restrictSize(5*cm, 5*cm)
img.hAlign = 'CENTER'
story.append(img)
story.append(Spacer(1, 0.5*cm))
# Add title
title = "Music Quiz Round"
if rnd.name:
title = rnd.name
story.append(Paragraph(title, title_style))
# Add subtitle with round type
round_type_text = f"Round Type: {rnd.round_type}"
if rnd.round_criteria_used:
round_type_text += f" - {rnd.round_criteria_used}"
story.append(Paragraph(round_type_text, subtitle_style))
# Add date info
current_date = datetime.now().strftime("%B %d, %Y")
story.append(Paragraph(f"Generated on {current_date}", info_style))
# Add divider
story.append(Spacer(1, 0.75*cm))
# Create song table data
data = [["#", "Artist", "Title", "Year", "Genre"]]
for i, song in enumerate(songs):
if song:
data.append([
f"{i+1}",
f"{song.artist}", # Artist in separate column
f"{song.title}", # Title in separate column
f"{song.year or ''}",
f"{song.genre or ''}"
])
# Create and style the table
table = Table(data, colWidths=[0.7*cm, 6*cm, 6*cm, 2*cm, 2.5*cm])
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#00ACC1')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, 0), 'CENTER'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 12),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('TOPPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.white),
('GRID', (0, 0), (-1, -1), 1, colors.lightgrey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('ALIGN', (0, 0), (0, -1), 'CENTER'), # Center the song numbers
('ALIGN', (3, 1), (3, -1), 'CENTER'), # Center the years
('FONTNAME', (0, 1), (0, -1), 'Helvetica-Bold'), # Make numbers bold
('FONTNAME', (1, 1), (1, -1), 'Helvetica-Bold'), # Make artist names bold
# Zebra striping for rows
('BACKGROUND', (0, 1), (-1, -1), colors.white),
]))
# Add alternating row colors
for i in range(1, len(data)):
if i % 2 == 0:
table.setStyle(TableStyle([
('BACKGROUND', (0, i), (-1, i), colors.HexColor('#F9FAFB'))
]))
story.append(table)
# Add spacer
story.append(Spacer(1, 0.75*cm))
# Add footer
footer_text = "Quizzical Beats - Music Quiz Generator"
story.append(Paragraph(footer_text, info_style))
# Build PDF
doc.build(story)
# Get the value from the buffer
buffer.seek(0)
# Save to file
with open(file_path, 'wb') as file:
file.write(buffer.getvalue())
return buffer.getvalue()
@rounds_bp.route('/<int:round_id>/pdf', methods=['POST'])
@login_required
def round_pdf(round_id):
"""Generate a PDF file for a round"""
rnd = db.session.get(Round, round_id)
if not rnd:
return jsonify({'success': False, 'error': 'Round not found'})
# Define path for the PDF file
pdfs_dir = '/data/pdfs'
if not os.path.exists(pdfs_dir):
os.makedirs(pdfs_dir)
pdf_file_path = os.path.join(pdfs_dir, f'round_{rnd.id}.pdf')
# Check if the PDF has already been generated and if the file exists
# We'll generate a new file if either the flag is False or the file doesn't exist
if rnd.pdf_generated and os.path.exists(pdf_file_path):
current_app.logger.info(f"PDF file already exists and is up to date at: {pdf_file_path}")
download_url = url_for('rounds.download_pdf', round_id=round_id)
# Check if this is an AJAX request
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': True,
'message': 'PDF file already exists',
'download_url': download_url
})
else:
# Traditional form submission, send file
return send_file(pdf_file_path, as_attachment=True)
try:
pdf_data = generate_pdf(round_id)
if isinstance(pdf_data, str) and pdf_data.startswith('Round not found'):
return jsonify({'success': False, 'error': pdf_data})
with open(pdf_file_path, 'wb') as f:
f.write(pdf_data)
# Update the round object to indicate PDF has been generated and update timestamp
rnd.pdf_generated = True
rnd.last_generated_at = datetime.utcnow()
db.session.commit()
# Check if this is an AJAX request
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
download_url = url_for('rounds.download_pdf', round_id=round_id)
return jsonify({
'success': True,
'message': 'PDF file successfully generated',
'download_url': download_url
})
else:
# Traditional form submission, send the file
return send_file(pdf_file_path, as_attachment=True)
except Exception as e:
error_msg = f"Error generating PDF file: {e}"
current_app.logger.error(error_msg)
return jsonify({'success': False, 'error': error_msg})
@rounds_bp.route('/<int:round_id>/mail', methods=['POST'])
@login_required
def send_email(round_id):
"""
Generate PDF + MP3, attach them to an email, and send via Postmark.
"""
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
# For AJAX requests, keep the response consistent
if not current_user.is_authenticated:
return jsonify({'error': 'Authentication required'}), 401
# Check if the round exists
rnd = Round.query.get_or_404(round_id)
# Generate PDF
pdf_data = generate_pdf(round_id)
if isinstance(pdf_data, str) and pdf_data.startswith('Round not found'):
error_msg = 'Round not found'
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': error_msg})
else:
flash(error_msg, 'error')
return redirect(url_for('rounds.rounds_list'))
# Define the path for the MP3 file
mp3_file_path = os.path.join('/data/rounds', f'round_{round_id}.mp3')
# Check if MP3 exists, if not generate it
if not os.path.exists(mp3_file_path):
# Call the round_mp3 function but don't return its result yet
# This will generate the MP3 file at mp3_file_path
response = round_mp3(round_id)
if isinstance(response, str) and response.startswith('Error'):
error_msg = response
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': error_msg})
else:
flash(error_msg, 'error')
return redirect(url_for('rounds.round_detail', round_id=round_id))
# Get mail configuration from environment variables
mail_host = current_app.config.get('MAIL_HOST')
mail_port = current_app.config.get('MAIL_PORT')
mail_username = current_app.config.get('MAIL_USERNAME')
mail_password = current_app.config.get('MAIL_PASSWORD')
mail_sender = current_app.config.get('MAIL_SENDER')
# Use the current user's email address as the recipient
mail_recipient = current_user.email
if not mail_recipient:
error_msg = "You don't have an email address in your profile. Please update your profile with an email address."
current_app.logger.error(error_msg)
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': error_msg})
else:
session['email_error'] = error_msg
return redirect(url_for('rounds.round_detail', round_id=round_id))
# Check if all email configuration parameters are available
missing_config = []
if not mail_host:
missing_config.append("MAIL_HOST")
if not mail_port:
missing_config.append("MAIL_PORT")
if not mail_username:
missing_config.append("MAIL_USERNAME")
if not mail_password:
missing_config.append("MAIL_PASSWORD")
if not mail_sender:
missing_config.append("MAIL_SENDER")
if missing_config:
missing_params = ", ".join(missing_config)
error_msg = f"Email server configuration is incomplete. Missing parameters: {missing_params}. Please check your .env file."
current_app.logger.error(f"Email configuration error: {error_msg}")
current_app.logger.error(f"Current config values - MAIL_HOST: {'set' if mail_host else 'missing'}, "
f"MAIL_PORT: {'set' if mail_port else 'missing'}, "
f"MAIL_USERNAME: {'set' if mail_username else 'missing'}, "
f"MAIL_PASSWORD: {'set' if mail_password else 'missing'}, "
f"MAIL_SENDER: {'set' if mail_sender else 'missing'}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': error_msg})
else:
session['email_error'] = error_msg # Store the error message in the session
return redirect(url_for('rounds.round_detail', round_id=round_id))
msg = MIMEMultipart()
msg['From'] = mail_sender
msg['To'] = mail_recipient
# Get the round name for the subject
round_title = f'Pub Quiz Round #{round_id}'
if rnd and rnd.name:
round_title = rnd.name
msg['Subject'] = round_title
msg.attach(MIMEText('Attached please find the MP3 and PDF files for the quiz round.', 'plain'))
# Attach PDF
pdf_attachment = MIMEBase('application', 'pdf')
pdf_attachment.set_payload(pdf_data)
encoders.encode_base64(pdf_attachment)
pdf_attachment.add_header('Content-Disposition', f'attachment; filename=round_{round_id}.pdf')
msg.attach(pdf_attachment)
# Attach MP3
with open(mp3_file_path, 'rb') as mp3_file:
mp3_data = mp3_file.read()
mp3_attachment = MIMEBase('audio', 'mpeg')
mp3_attachment.set_payload(mp3_data)
encoders.encode_base64(mp3_attachment)
mp3_attachment.add_header('Content-Disposition', f'attachment; filename=round_{round_id}.mp3')
msg.attach(mp3_attachment)
try:
current_app.logger.info(f"Attempting to send email to {mail_recipient} via {mail_host}:{mail_port}")
with smtplib.SMTP(mail_host, mail_port) as server:
server.starttls()
current_app.logger.debug("STARTTLS established")
server.login(mail_username, mail_password)
current_app.logger.debug(f"Login successful for {mail_username}")
server.sendmail(mail_sender, mail_recipient, msg.as_string())
current_app.logger.info(f"Email sent successfully from {mail_sender} to {mail_recipient}")
success_msg = f'Email sent successfully to {mail_recipient}!'
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': True, 'message': success_msg})
else:
flash(success_msg, 'success')
return redirect(url_for('rounds.round_detail', round_id=round_id))
except smtplib.SMTPException as e:
error_msg = str(e)
current_app.logger.error(f"SMTP Error: {error_msg}")
current_app.logger.error(f"Failed to send email from {mail_sender} to {mail_recipient} via {mail_host}:{mail_port}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'error': error_msg})
else:
session['email_error'] = error_msg
return redirect(url_for('rounds.round_detail', round_id=round_id))
@rounds_bp.route('/<int:round_id>/delete', methods=['POST'])
@login_required
def delete_round(round_id):
"""Delete a round and its associated files"""
rnd = Round.query.get_or_404(round_id)
try:
# Delete associated MP3 file if it exists
mp3_file_path = os.path.join('/data/rounds', f'round_{round_id}.mp3')
if os.path.exists(mp3_file_path):
os.remove(mp3_file_path)
# Delete associated PDF file if it exists
pdf_file_path = os.path.join('/data/pdfs', f'round_{round_id}.pdf')
if os.path.exists(pdf_file_path):
os.remove(pdf_file_path)
# Delete the round from the database
db.session.delete(rnd)
db.session.commit()
flash('Round deleted successfully', 'success')
return jsonify({'success': True})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error deleting round: {e}")
flash(f"Error deleting round: {e}", 'error')
return jsonify({'success': False, 'error': str(e)}), 500
@rounds_bp.route('/<int:round_id>/export-to-dropbox', methods=['POST'])
@login_required
def export_to_dropbox(round_id):
"""
Export a round to the user's Dropbox account, including metadata and optionally MP3 files
"""
current_app.logger.info(f"Starting Dropbox export for round ID {round_id} by user {current_user.username}")
round_obj = Round.query.get_or_404(round_id)
# Properly parse boolean parameters from form data
include_mp3s = request.form.get('include_mp3s', 'true').lower() == 'true'
include_pdf = request.form.get('include_pdf', 'true').lower() == 'true'
custom_folder = request.form.get('custom_folder', '')
current_app.logger.debug(f"Export options - Include MP3: {include_mp3s}, Include PDF: {include_pdf}, Custom folder: '{custom_folder}'")
# Default response format
response_data = {
'success': False,
'message': 'Unknown error occurred',
'shared_links': {
'mp3': None,
'pdf': None,
'text': None # For metadata JSON
}
}
# Validate user has Dropbox connected
if not current_user.dropbox_token or not current_user.dropbox_refresh_token:
current_app.logger.error(f"User {current_user.username} attempted Dropbox export without connected account")
response_data['message'] = 'You need to connect your Dropbox account first'
flash('Please connect your Dropbox account in your profile settings first.', 'error')
return jsonify(response_data)
current_app.logger.debug(f"Dropbox token exists: {bool(current_user.dropbox_token)}, token expiry: {current_user.dropbox_token_expiry}")
# Refresh token if needed
from musicround.helpers.dropbox_helper import refresh_dropbox_token_if_needed
token_refresh = refresh_dropbox_token_if_needed(current_user)
current_app.logger.info(f"Token refresh result: {token_refresh}")
if not token_refresh['success']:
current_app.logger.error(f"Failed to refresh Dropbox token: {token_refresh['message']}")
response_data['message'] = f"Error with Dropbox authentication: {token_refresh['message']}"
flash('There was an error with your Dropbox connection. Please reconnect in your profile.', 'error')
return jsonify(response_data)
# Initialize the round export record
from musicround.models import RoundExport, db
round_export = RoundExport(
round_id=round_id,
user_id=current_user.id,
export_type='dropbox',
include_mp3s=include_mp3s,
status='in_progress',
destination=current_user.dropbox_export_path
)
db.session.add(round_export)
db.session.commit()
current_app.logger.debug(f"Created export record with ID: {round_export.id}")
try:
# Determine base export path
base_folder = current_user.dropbox_export_path or '/QuizzicalBeats'
if custom_folder:
base_folder = os.path.join(base_folder, custom_folder.strip('/'))
current_app.logger.debug(f"Using base folder: {base_folder}")
# Create a folder for this round
round_folder_name = f"Round_{round_id}"
if round_obj.name:
# Sanitize round name for folder name (remove invalid characters)
safe_name = re.sub(r'[<>:"/\\|?*]', '', round_obj.name)
round_folder_name = f"Round_{round_id}_{safe_name}"
round_folder = os.path.join(base_folder, round_folder_name)
metadata_folder = os.path.join(round_folder, "Metadata")
current_app.logger.debug(f"Round folder path: {round_folder}")
current_app.logger.debug(f"Metadata folder path: {metadata_folder}")
# Access token for Dropbox API calls
access_token = current_user.dropbox_token
current_app.logger.debug(f"Access token (first 10 chars): {access_token[:10] if access_token else 'None'}")
# Prepare song list - check if the helper method works
try:
songs = round_obj.song_list
current_app.logger.debug(f"Successfully retrieved {len(songs)} songs")
except Exception as song_err:
current_app.logger.error(f"Error getting song list: {str(song_err)}")
# Fallback method to get songs
song_ids = [int(sid) for sid in round_obj.songs.split(',')]
songs = Song.query.filter(Song.id.in_(song_ids)).all()
current_app.logger.debug(f"Fallback method retrieved {len(songs)} songs")
# 1. Export song metadata as JSON
song_data = []
for song in songs:
try:
song_dict = song.to_dict()
song_data.append(song_dict)
except Exception as e:
current_app.logger.error(f"Error converting song {song.id} to dict: {str(e)}")
# Manual conversion fallback
song_data.append({
'id': song.id,
'title': song.title,
'artist': song.artist,
'year': song.year,
'deezer_id': song.deezer_id,
'spotify_id': song.spotify_id,
})
metadata_json = json.dumps({
'round_id': round_obj.id,
'round_name': round_obj.name,
'round_type': round_obj.round_type,
'round_criteria': round_obj.round_criteria_used,
'created_at': round_obj.created_at.isoformat() if round_obj.created_at else None,
'songs': song_data
}, indent=2)
current_app.logger.debug(f"Created metadata JSON ({len(metadata_json)} bytes)")
# Upload metadata JSON
from musicround.helpers.dropbox_helper import upload_to_dropbox, create_shared_link
json_path = f"{metadata_folder}/round_{round_id}_metadata.json"
current_app.logger.info(f"Uploading JSON metadata to {json_path}")
json_upload = upload_to_dropbox(
access_token,
json_path,
metadata_json,
mode='text'
)
current_app.logger.debug(f"JSON upload result: {json_upload}")
if not json_upload['success']:
current_app.logger.error(f"Error uploading JSON metadata: {json_upload}")
raise Exception(f"Error uploading JSON metadata: {json_upload['message']}")
# Create shared link for JSON
current_app.logger.info(f"Creating shared link for JSON at {json_path}")
json_link = create_shared_link(access_token, json_path)
current_app.logger.debug(f"JSON shared link result: {json_link}")
if json_link['success']:
response_data['shared_links']['text'] = json_link['url']
# 2. Export PDF if requested
if include_pdf:
current_app.logger.info(f"PDF export requested for round {round_id}")
# Generate PDF if not already generated
if not round_obj.pdf_generated:
current_app.logger.debug("PDF not generated yet, generating now")
pdf_data = generate_pdf(round_id)
if isinstance(pdf_data, str) and pdf_data.startswith('Round not found'):
current_app.logger.error(f"Error generating PDF: {pdf_data}")
raise Exception(f"Error generating PDF: {pdf_data}")
else:
# PDF already exists, read it
pdf_file_path = os.path.join('/data/pdfs', f'round_{round_id}.pdf')
current_app.logger.debug(f"Reading existing PDF from {pdf_file_path}")
if not os.path.exists(pdf_file_path):
current_app.logger.error(f"PDF file doesn't exist at {pdf_file_path} despite pdf_generated=True")
# Generate it anyway
pdf_data = generate_pdf(round_id)
else:
with open(pdf_file_path, 'rb') as f:
pdf_data = f.read()
current_app.logger.debug(f"Read {len(pdf_data)} bytes from PDF file")
# Upload PDF
pdf_path = f"{round_folder}/round_{round_id}.pdf"
current_app.logger.info(f"Uploading PDF to {pdf_path}")
pdf_upload = upload_to_dropbox(
access_token,
pdf_path,
pdf_data
)
current_app.logger.debug(f"PDF upload result: {pdf_upload}")
if not pdf_upload['success']:
current_app.logger.error(f"Error uploading PDF: {pdf_upload}")
raise Exception(f"Error uploading PDF: {pdf_upload['message']}")
# Create shared link for PDF
current_app.logger.info(f"Creating shared link for PDF at {pdf_path}")
pdf_link = create_shared_link(access_token, pdf_path)
current_app.logger.debug(f"PDF shared link result: {pdf_link}")
if pdf_link['success']:
response_data['shared_links']['pdf'] = pdf_link['url']
# 3. Export MP3 if requested
if include_mp3s:
current_app.logger.info(f"MP3 export requested for round {round_id}")
# Check if MP3 exists, generate if not
mp3_file_path = os.path.join('/data/rounds', f'round_{round_id}.mp3')
current_app.logger.debug(f"Checking for MP3 at {mp3_file_path}")
current_app.logger.debug(f"MP3 generated flag: {round_obj.mp3_generated}")
if not round_obj.mp3_generated or not os.path.exists(mp3_file_path):
current_app.logger.warning(f"MP3 needs to be generated first. Generated flag: {round_obj.mp3_generated}, File exists: {os.path.exists(mp3_file_path)}")
# Need to redirect to MP3 generation first
response_data['success'] = False
response_data['message'] = 'MP3 needs to be generated first'
response_data['redirect'] = url_for('rounds.round_mp3', round_id=round_id)
# Update export record
round_export.status = 'pending_mp3'
round_export.error_message = 'MP3 needs to be generated first'
db.session.commit()
return jsonify(response_data)
# MP3 exists, upload it
current_app.logger.debug(f"Reading MP3 file from {mp3_file_path}")
with open(mp3_file_path, 'rb') as f:
mp3_data = f.read()
current_app.logger.debug(f"Read {len(mp3_data)} bytes from MP3 file")
mp3_path = f"{round_folder}/round_{round_id}.mp3"
current_app.logger.info(f"Uploading MP3 to {mp3_path}")
mp3_upload = upload_to_dropbox(
access_token,
mp3_path,
mp3_data
)
current_app.logger.debug(f"MP3 upload result: {mp3_upload}")
if not mp3_upload['success']:
current_app.logger.error(f"Error uploading MP3: {mp3_upload}")
raise Exception(f"Error uploading MP3: {mp3_upload['message']}")
# Create shared link for MP3
current_app.logger.info(f"Creating shared link for MP3 at {mp3_path}")
mp3_link = create_shared_link(access_token, mp3_path)
current_app.logger.debug(f"MP3 shared link result: {mp3_link}")
if mp3_link['success']:
response_data['shared_links']['mp3'] = mp3_link['url']
# Update export record as success
round_export.status = 'success'
db.session.commit()
current_app.logger.info(f"Export to Dropbox completed successfully for round {round_id}")
# Success response
response_data['success'] = True
response_data['message'] = 'Round exported to Dropbox successfully'
return jsonify(response_data)
except Exception as e:
current_app.logger.error(f"Error exporting round {round_id} to Dropbox: {str(e)}", exc_info=True)
# Update export record with error
round_export.status = 'failed'
round_export.error_message = str(e)
db.session.commit()
# Error response
response_data['success'] = False
response_data['message'] = f"Error exporting to Dropbox: {str(e)}"
return jsonify(response_data)
def generate_round_text(round_obj):
"""Generate a text representation of a round"""
lines = [
f"ROUND: {round_obj.title}",
f"Created: {round_obj.created_at.strftime('%Y-%m-%d')}",
f"Creator: {round_obj.user.username if round_obj.user else 'Unknown'}",
"",
f"Description: {round_obj.description or 'No description'}",
"",
"SONGS:",
""
]
for idx, song in enumerate(round_obj.songs, 1):
lines.append(f"{idx}. {song.title} - {song.artist}")
if song.year:
lines.append(f" Year: {song.year}")
if song.album:
lines.append(f" Album: {song.album}")
lines.append("")
return "\n".join(lines)
def generate_round_pdf(round_obj):
"""Generate a PDF representation of a round"""
try:
from reportlab.lib.pagesizes import A4
from reportlab.lib import colors
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from io import BytesIO
buffer = BytesIO()
doc = SimpleDocTemplate(buffer, pagesize=A4)
styles = getSampleStyleSheet()
# Create custom styles
styles.add(ParagraphStyle(
name='Title',
parent=styles['Heading1'],
fontSize=16,
spaceAfter=12
))
styles.add(ParagraphStyle(
name='SongTitle',
parent=styles['Normal'],
fontSize=12,
fontName='Helvetica-Bold'
))
# Build the document content
content = []
# Round title
content.append(Paragraph(f"Round: {round_obj.title}", styles['Title']))
content.append(Spacer(1, 12))
# Round info
content.append(Paragraph(f"Created: {round_obj.created_at.strftime('%Y-%m-%d')}", styles['Normal']))
content.append(Paragraph(f"Creator: {round_obj.user.username if round_obj.user else 'Unknown'}", styles['Normal']))
content.append(Spacer(1, 12))
# Description
if round_obj.description:
content.append(Paragraph("Description:", styles['Heading3']))
content.append(Paragraph(round_obj.description, styles['Normal']))
content.append(Spacer(1, 12))
# Songs
content.append(Paragraph("Songs:", styles['Heading3']))
content.append(Spacer(1, 6))
for idx, song in enumerate(round_obj.songs, 1):
content.append(Paragraph(f"{idx}. {song.title} - {song.artist}", styles['SongTitle']))
# Song details
details = []
if song.year:
details.append(f"Year: {song.year}")
if song.album:
details.append(f"Album: {song.album}")
if details:
content.append(Paragraph(", ".join(details), styles['Normal']))
content.append(Spacer(1, 6))
# Build and return the PDF
doc.build(content)
pdf_data = buffer.getvalue()
buffer.close()
return pdf_data
except ImportError:
current_app.logger.warning("ReportLab not installed, skipping PDF export")
return None
except Exception as e:
current_app.logger.error(f"Error generating PDF: {str(e)}")
return None
def safe_filename(filename):
"""Convert a string to a safe filename"""
# Replace problematic characters
safe_name = re.sub(r'[^\w\s-]', '', filename).strip().replace(' ', '_')
return safe_name