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('/') @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('//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('//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//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_', 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_', 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('//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('//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('//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('//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