""" app.py ====== Main Flask application for the Theme Song Booth (MusicGPT Edition). This module defines all HTTP routes, helper functions, the email layer, runtime settings persistence, MP3 metadata tagging, rate limiting, and database health/maintenance helpers. It is meant to be served by gunicorn inside a Docker container (see Dockerfile). Public routes (customers): - / -> redirects to /request - /request -> customer submits their info - /thanks/ -> confirmation page after submission - /play/ -> private player page with Version A and B - /play//approve -> customer picks a version - /play//revise -> customer asks for changes - /api/stream//.mp3 -> streams uploaded MP3s (requires both A and B) - /api/audio-source//.mp3 -> serves a single MP3 for stems extraction API - /audio//.mp3 -> legacy, returns 404 Admin routes: - /admin/login -> password login - /admin/logout -> clears session - /admin -> dashboard queue with status filters and auto-refresh - /admin/settings -> runtime settings, health check, DB stats, backup/restore, reset - /admin/request/ -> detail/edit page for a single request - /admin/request//delete -> deletes one request and its uploaded files - /admin/request//download-stems -> downloads all stems as a zip archive - /admin/reset -> deletes ALL requests and ALL files API routes: - /api/musicgpt/webhook -> MusicGPT async callback (generation + stems) - /api/musicgpt/autopoll -> background poll for all in-flight tasks - /api/prompt/ -> Hermes callback for generated prompts - /api/key-test -> tests the Hermes API key """ # Standard library imports import os import hmac import shutil import time import json import io import zipfile import re from pathlib import Path # Flask and related imports from flask import Flask, request, render_template, redirect, url_for, flash, session, abort, current_app, send_file, jsonify from flask_limiter import Limiter from flask_limiter.util import get_remote_address # Project imports from config import Config from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests, get_db from models import SCHEMA, get_requests_by_email, log_revision, list_revision_history # Helpers (extracted from app.py to keep the route file manageable) from helpers import ( MUSIC_GENRES, DECADES, parse_style_genre, build_style_genre, is_admin, require_admin, admin_password_ok, is_valid_email, allowed_file, upload_path, save_upload, apply_mp3_tags, encrypt_value, decrypt_value, decrypt_value_legacy, settings_file_path, load_booth_settings, save_booth_settings, get_email_config, get_refresh_seconds, get_kiosk_cycle_seconds, get_kiosk_mode, get_max_revisions, get_callback_expiry_hours, get_hermes_api_key, set_hermes_api_key, generate_hermes_api_key, mask_api_key, get_musicgpt_api_key, get_musicgpt_default_model, get_musicgpt_models, get_musicgpt_autopoll_enabled, set_musicgpt_autopoll_enabled, build_musicgpt_webhook_url, musicgpt_generate_request, musicgpt_queue_stems, musicgpt_poll_status, download_musicgpt_outputs, format_musicgpt_cost, get_musicgpt_cost_totals, download_album_cover, process_stems_to_gokapi, get_ntfy_config, send_ntfy, sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url, send_email, build_signature_images, get_booth_open, ) # --------------------------------------------------------------------------- # App setup # --------------------------------------------------------------------------- # Create the Flask app and load configuration from Config class. app = Flask(__name__) app.config.from_object(Config) # Template filter: format UTC ISO timestamp as "Tuesday, August 8, 2026 @ 9:24pm" in America/Regina. from datetime import datetime, timezone from zoneinfo import ZoneInfo _REGINA_TZ = ZoneInfo("America/Regina") @app.template_filter("regina_dt") def regina_dt_filter(value): if not value: return "" try: dt = datetime.fromisoformat(value) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) dt = dt.astimezone(_REGINA_TZ) return dt.strftime("%A, %B %-d, %Y @ %-I:%M%p").replace("AM", "am").replace("PM", "pm") except (ValueError, TypeError): return value # Global request rate limiting by remote IP (default 60/min). The public form is further limited to 5/min. limiter = Limiter(get_remote_address, app=app, default_limits=["60 per minute"]) # Ensure the SQLite connection is closed at the end of each request. app.teardown_appcontext(close_db) # Human-readable labels for each status value stored in the database. STATUS_LABELS = { 'pending': 'Pending', 'prompt_ready': 'Prompt Ready', 'songs_uploaded': 'Songs Uploaded — Awaiting Approval', 'revisions_requested': 'Revisions Requested', 'awaiting_payment': 'Awaiting Payment', 'paid': 'Paid', 'delivered': 'Delivered', 'cancelled': 'Cancelled', } def get_request_steps(req): """ Return a linear step sequence and the current step index for the request lifecycle. Steps follow the standard flow: Pending -> Prompt Ready -> Songs Ready -> Approved -> Delivered. Special statuses like 'revisions_requested' and 'paid' are mapped to the closest visible step. Cancelled requests are returned with index -1 so the UI can render a cancelled state. """ steps = [ ('pending', 'Pending'), ('prompt_ready', 'Prompt Ready'), ('songs_uploaded', 'Songs Ready'), ('awaiting_payment', 'Approved'), ('delivered', 'Delivered'), ] status = req.get('status', 'pending') if status == 'cancelled': return steps, -1 if status == 'revisions_requested': return steps, 2 # still at Songs Ready visually, with a revision note if status == 'paid': return steps, 3 # payment recorded, awaiting final delivery for i, (key, _) in enumerate(steps): if key == status: return steps, i return steps, 0 # --------------------------------------------------------------------------- # Public customer routes # --------------------------------------------------------------------------- @app.route('/') def index(): """Root route: redirect customers straight to the request form.""" return redirect(url_for('request_form')) @app.route('/request', methods=['GET', 'POST']) @limiter.limit("5 per minute") def request_form(): """ Public request form. GET -> shows the form with the banner image, or a closed message if the booth is closed. POST -> validates the email, creates a database record, sends a confirmation email, and redirects to the thanks page. Rate limited to 5 submissions per minute per IP. """ if not get_booth_open(): return render_template('closed.html') if request.method == 'POST': decade = request.form.get('decade', '').strip() basic_style = request.form.get('basic_style', '').strip() additional_style = request.form.get('additional_style', '').strip() pronouns = request.form.get('pronouns', '').strip() if not pronouns: flash('Please select your pronouns.', 'error') return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 if not decade: flash('Please select a decade.', 'error') return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 if not basic_style: flash('Please select a basic style.', 'error') return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 form_data = { 'name': request.form.get('name', '').strip(), 'email': request.form.get('email', '').strip().lower(), 'pronouns': pronouns, 'hobbies': request.form.get('hobbies', '').strip()[:2000], 'notable_facts': request.form.get('notable_facts', '').strip()[:2000], 'style_genre': build_style_genre(decade, basic_style, additional_style), 'extra_requests': request.form.get('extra_requests', '').strip()[:2000], 'vocal_gender': request.form.get('vocal_gender', '').strip(), 'stems_interest': bool(request.form.get('stems_interest')), } if not is_valid_email(form_data['email']): flash('Please enter a valid email address.', 'error') return render_template('request.html', form=form_data, decades=DECADES, genres=MUSIC_GENRES), 400 rid = create_request(**form_data) # Notify operator via ntfy when a new request comes in. try: send_ntfy( f"New request #{rid} from {form_data['name']} ({form_data['email']})\nStyle: {form_data['style_genre'] or '-'}", title='New Theme Song Request', priority='high', tags='musical_note' ) except Exception: # Push notification failure should never break the customer form. pass # Send confirmation email with a summary of what the customer asked for. req = get_request_by_id(rid) if req: try: body_lines = [ f"Hi {req['name']},", "", "Thanks for stopping by the Trollgorithm Theme Song Booth! We've received your request and will start crafting your custom song soon.", "", "Here's what we have on file:", f"Name: {req['name']}", f"Email: {req['email']}", f"Pronouns: {req['pronouns'] or '-'}", f"Style / genre: {req['style_genre'] or '-'}", f"Preferred singer voice / gender: {req['vocal_gender'] or 'No preference'}", f"Hobbies: {req['hobbies'] or '-'}", f"Notable facts: {req['notable_facts'] or '-'}", f"Extra requests: {req['extra_requests'] or '-'}", f"Interested in STEMS: {'Yes' if req.get('stems_interest') else 'No'}", "", "You'll get another email with a private link to preview two versions of your song when they're ready.", "", "— Trollgorithm / Dionysis Media" ] send_email(req['email'], 'Your theme song request is received', '\n'.join(body_lines), inline_images=build_signature_images()) except Exception as e: flash(f'Your request was saved, but we could not send a confirmation email: {e}', 'error') flash('Your request has been submitted! Check your email soon.', 'success') return redirect(url_for('thanks', rid=rid)) return render_template('request.html', form=None, decades=DECADES, genres=MUSIC_GENRES) @app.route('/api/ping', methods=['GET']) @app.route('/api/key-test', methods=['GET']) @limiter.limit('4 per minute') def api_key_test(): """ Diagnostic endpoint for verifying the Hermes API key configuration. Accepts a Bearer token in the Authorization header and compares it against the configured HERMES_API_KEY. Returns plain JSON so callers can distinguish key mismatch from networking / signed-token issues. Rate limited to 4 per minute to prevent brute-force guessing. """ expected_key = get_hermes_api_key() if not expected_key: return jsonify({'ok': False, 'reason': 'not_configured'}), 500 auth_header = request.headers.get('Authorization', '').strip() if not auth_header.startswith('Bearer '): return jsonify({'ok': False, 'reason': 'missing_bearer'}), 401 provided_key = auth_header[7:].strip() if not hmac.compare_digest(expected_key, provided_key): return jsonify({'ok': False, 'reason': 'key_mismatch'}), 401 return jsonify({'ok': True, 'reason': 'valid'}), 200 @app.route('/api/prompt/', methods=['POST']) def api_update_prompt(rid): """ Hermes callback endpoint. Accepts a JSON POST with generated Suno prompt fields and updates the matching request. Two layers of auth: 1) A per-request signed callback token in the query string. 2) A Hermes API key in the Authorization header (Bearer). Only records in 'pending' status can be updated. On success, status is set to 'prompt_ready'. """ # Layer 1: verify the signed callback URL token. callback_token = request.args.get('token', '').strip() token_rid, token_ok = verify_prompt_callback(callback_token) if not token_ok or token_rid != rid: abort(401) # Layer 2: verify the Hermes API key from the Authorization header. expected_key = get_hermes_api_key() if not expected_key: abort(500, description='Hermes API key is not configured') auth_header = request.headers.get('Authorization', '').strip() if not auth_header.startswith('Bearer '): abort(401) provided_key = auth_header[7:].strip() if not hmac.compare_digest(expected_key, provided_key): abort(401) req = get_request_by_id(rid) if not req: abort(404) # Allow updates when the request is pending or when a revision has been requested. if req['status'] not in ('pending', 'revisions_requested'): abort(409, description='Request is no longer pending or awaiting revision') data = request.get_json(silent=True) or {} # Optional email verification to make sure clipboard matches record. provided_email = data.get('email', '').strip().lower() if provided_email and provided_email != req['email'].lower(): abort(400, description='Email mismatch') # When this is a revision, keep the revision note and status as revisions_requested # so the operator still sees it as "needs new songs", and preserve customer_approved. new_status = 'revisions_requested' if req['status'] == 'revisions_requested' else 'prompt_ready' update_request(rid, suno_title=data.get('suno_title', '').strip(), suno_style=data.get('suno_style', '').strip(), suno_lyrics=data.get('suno_lyrics', '').strip(), status=new_status ) return jsonify({'ok': True, 'request_id': rid, 'status': new_status}), 200 @app.route('/api/musicgpt/webhook', methods=['POST']) def musicgpt_webhook(): """ Public webhook endpoint for MusicGPT async job completion. Handles both Music AI generation webhooks and Extraction (stems) webhooks. Generation webhooks may arrive multiple times per task (one per version, plus lyrics, plus streaming URL, plus album cover). We update the request incrementally and download files when a COMPLETED conversion payload arrives. """ data = request.get_json(silent=True) or {} task_id = data.get('task_id') or data.get('taskId') conversion_type = data.get('conversion_type') or data.get('conversionType') or '' status = data.get('status') or data.get('conversion_status') or 'COMPLETED' if not task_id: return jsonify({'ok': False, 'reason': 'missing_task_id'}), 400 conversion_id = data.get('conversion_id') # Music AI generation webhook if 'Music' in conversion_type or 'Music AI' in conversion_type or not conversion_type: db = get_db() row = db.execute('SELECT * FROM requests WHERE musicgpt_task_id = ?', (task_id,)).fetchone() if not row: return jsonify({'ok': False, 'reason': 'request_not_found'}), 404 req = dict(row) # Determine which version this webhook belongs to. # MusicGPT sends a separate webhook for each conversion_id; version A is the # first conversion queued and version B is the second. We store both IDs on # the request so we can route the payload correctly and decide which file to # overwrite when a later webhook arrives for the same version. version = None if req.get('musicgpt_conversion_id_1') and conversion_id == req['musicgpt_conversion_id_1']: version = 'A' elif req.get('musicgpt_conversion_id_2') and conversion_id == req['musicgpt_conversion_id_2']: version = 'B' else: # Fallback: if only one conversion_id stored, or single-webhook payload. if not req.get('musicgpt_conversion_id_1'): version = 'A' elif not req.get('musicgpt_conversion_id_2'): version = 'B' new_status = status.upper() update_fields = {'musicgpt_status': new_status} if data.get('is_flagged'): update_fields['musicgpt_error'] = data.get('reason') or 'Flagged by MusicGPT' if new_status in ('COMPLETED', 'FINISHED'): # Webhooks often fire before audio URLs are ready. Store whatever the # webhook gives us (album cover, cost), then attempt download in case # the webhook is the rare complete one. if data.get('album_cover_path'): update_fields['album_cover_url'] = data.get('album_cover_path') # Accumulate cost from each per-version webhook instead of overwriting. # The estimate is set when the job is queued; webhooks may report # per-conversion costs. We sum them into the total. try: cc = float(data.get('conversion_cost') or 0) except (ValueError, TypeError): cc = 0 if cc > 0: # Only accumulate if the webhook reports a non-zero cost; # otherwise keep whatever was already stored (estimate or prior sum). current = float(req.get('musicgpt_cost') or 0) # If this is a per-version webhook, add to the total. # First webhook for version A: total = cc. Second for B: total += cc. # If the estimate was stored and no per-version cost was reported yet, # the first real cost replaces the estimate. if version == 'A': update_fields['musicgpt_cost'] = cc + float(req.get('musicgpt_cost_b') or 0) update_fields['musicgpt_cost_a'] = cc elif version == 'B': update_fields['musicgpt_cost'] = float(req.get('musicgpt_cost_a') or 0) + cc update_fields['musicgpt_cost_b'] = cc else: update_fields['musicgpt_cost'] = cc download_musicgpt_outputs(req, data, version=version) row = db.execute('SELECT * FROM requests WHERE id = ?', (req['id'],)).fetchone() req = dict(row) if req.get('song_a_path') and req.get('song_b_path'): update_fields['status'] = 'songs_uploaded' elif new_status in ('FAILED', 'ERROR'): update_fields['musicgpt_error'] = data.get('reason') or data.get('error') or 'MusicGPT reported failure' update_request(req['id'], **update_fields) return jsonify({'ok': True, 'request_id': req['id']}), 200 # Extraction / stems webhook if 'Extraction' in conversion_type: db = get_db() row = db.execute('SELECT * FROM requests WHERE stems_task_id = ?', (task_id,)).fetchone() if not row: return jsonify({'ok': False, 'reason': 'request_not_found'}), 404 req = dict(row) new_status = status.upper() update_fields = {'stems_status': new_status} if new_status in ('COMPLETED', 'FINISHED'): audio_url_map = data.get('audio_url') if isinstance(audio_url_map, str): try: audio_url_map = json.loads(audio_url_map) except Exception: audio_url_map = {} # Prefer a zip-style bundle URL if present, otherwise join stem URLs. stems_url = data.get('bundle_url') or data.get('download_url') if not stems_url and audio_url_map: stems_url = '; '.join(f"{k}: {v}" for k, v in audio_url_map.items()) update_fields['stems_url'] = stems_url try: sc = float(data.get('conversion_cost') or 0) except (ValueError, TypeError): sc = 0 if sc > 0: update_fields['stems_cost'] = sc elif new_status in ('FAILED', 'ERROR'): update_fields['stems_error'] = data.get('reason') or data.get('error') or 'Extraction failed' update_request(req['id'], **update_fields) # After stems are complete, upload them to Gokapi for a shareable link. if new_status in ('COMPLETED', 'FINISHED') and Config.GOKAPI_URL and Config.GOKAPI_API_KEY: row = db.execute('SELECT * FROM requests WHERE id = ?', (req['id'],)).fetchone() req = dict(row) gokapi_url, gokapi_err = process_stems_to_gokapi(req) if gokapi_err: app.logger.warning(f'Gokapi upload failed for request {req["id"]}: {gokapi_err}') # Note: process_stems_to_gokapi already saves stems_link to the DB on success. return jsonify({'ok': True, 'request_id': req['id']}), 200 return jsonify({'ok': False, 'reason': 'unknown_conversion_type'}), 400 @app.route('/admin/musicgpt/autopoll', methods=['POST']) def admin_musicgpt_autopoll(): """ Internal endpoint used by the cron job for automatic MusicGPT polling. Unlike the manual dashboard refresh endpoint, this route first checks the 'musicgpt_autopoll' runtime setting. If automatic polling is disabled it returns immediately without touching the MusicGPT API, so operators can turn background automation on/off from /admin/settings without stopping the cron job. """ redir = require_admin() if redir: return redir if not get_musicgpt_autopoll_enabled(): return jsonify({'ok': True, 'autopoll': False, 'message': 'Automatic polling is disabled in settings.'}), 200 return admin_musicgpt_refresh() @app.route('/admin/musicgpt/refresh', methods=['POST']) def admin_musicgpt_refresh(): """ Manual dashboard action: poll all in-flight MusicGPT tasks and update statuses. This is intentionally separate from /admin/musicgpt/autopoll so operators can always trigger a manual refresh even when automatic polling is disabled. """ redir = require_admin() if redir: return redir db = get_db() rows = db.execute( "SELECT id, musicgpt_task_id, musicgpt_status FROM requests WHERE musicgpt_status IN ('IN_QUEUE', 'IN_PROGRESS')" ).fetchall() updated = 0 failed = 0 for row in rows: task_id = row['musicgpt_task_id'] if not task_id: continue result = musicgpt_poll_status(task_id) conversion = result.get('conversion') or {} status = (conversion.get('status') or result.get('status') or '').upper() req = get_request_by_id(row['id']) if not req: continue if status in ('COMPLETED', 'FINISHED'): download_musicgpt_outputs(req, conversion) # Re-fetch so we can see the newly saved paths. req = get_request_by_id(row['id']) if req: fields = {'musicgpt_status': 'COMPLETED'} if req.get('song_a_path') and req.get('song_b_path'): fields['status'] = 'songs_uploaded' update_request(row['id'], **fields) updated += 1 elif status in ('FAILED', 'ERROR'): update_request(row['id'], musicgpt_status='FAILED', musicgpt_error=(conversion.get('status_msg') if isinstance(conversion, dict) else None) or 'Polling reported failure') failed += 1 elif status: update_request(row['id'], musicgpt_status=status) updated += 1 flash(f'MusicGPT refresh: {updated} updated, {failed} failed.', 'success' if not failed else 'warning') return redirect(url_for('admin_dashboard')) @app.route('/admin/musicgpt/poll/', methods=['POST']) def admin_musicgpt_poll_request(rid): """ Poll a single MusicGPT task from the admin request page and download files if ready. This gives operators a way to force-update one request without waiting for the dashboard refresh or the automatic background poll. """ redir = require_admin() if redir: return redir req = get_request_by_id(rid) if not req: flash('Request not found.', 'error') return redirect(url_for('admin_dashboard')) task_id = req.get('musicgpt_task_id') if not task_id: flash('No MusicGPT task for this request.', 'error') return redirect(url_for('admin_request', rid=rid)) result = musicgpt_poll_status(task_id) conversion = result.get('conversion') or {} status = (conversion.get('status') or result.get('status') or '').upper() if status in ('COMPLETED', 'FINISHED'): download_musicgpt_outputs(req, conversion) req = get_request_by_id(rid) fields = {'musicgpt_status': 'COMPLETED'} if req and req.get('song_a_path') and req.get('song_b_path'): fields['status'] = 'songs_uploaded' update_request(rid, **fields) flash('MusicGPT task completed and files downloaded.', 'success') elif status in ('FAILED', 'ERROR'): update_request(rid, musicgpt_status='FAILED', musicgpt_error=(conversion.get('status_msg') if isinstance(conversion, dict) else None) or 'Polling reported failure') flash('MusicGPT task failed.', 'error') elif status: update_request(rid, musicgpt_status=status) flash(f'MusicGPT status: {status}', 'info') else: flash('Could not determine MusicGPT status.', 'warning') return redirect(url_for('admin_request', rid=rid)) @app.route('/thanks/') def thanks(rid): """Confirmation page shown after a customer submits a request.""" req = get_request_by_id(rid) if not req: abort(404) return render_template('thanks.html', req=req) @app.route('/status', methods=['GET', 'POST']) def status_lookup(): """ Public order status lookup page. Customers enter their email to see all their requests and their current statuses, plus the private player link once songs have been uploaded. """ requests_list = [] email = '' searched = False if request.method == 'POST': email = request.form.get('email', '').strip().lower() if not is_valid_email(email): flash('Please enter a valid email address.', 'error') else: requests_list = get_requests_by_email(email) searched = True return render_template('status.html', email=email, requests=requests_list, searched=searched, statuses=STATUS_LABELS) @app.route('/faq') def faq(): """Customer-facing frequently asked questions page.""" return render_template('faq.html') @app.route('/play/') def play(token): """ Private player page for a customer. The token is a cryptographically random URL-safe string generated at request time. Shows A/B audio players, approval buttons, or a revision note depending on status. """ req = get_request_by_token(token) if not req: abort(404) # Load runtime max revisions setting. max_revisions = get_max_revisions() revisions_left = max(0, max_revisions - int(req.get('revision_count') or 0)) return render_template('player.html', req=req, revisions_left=revisions_left, deliver_album_cover=load_booth_settings().get('deliver_album_cover', False)) @app.route('/play//approve', methods=['POST']) def approve(token): """ Customer has chosen Version A, Version B, or both. Updates the request status to 'awaiting_payment' so the operator can collect payment. Operator email alerts are intentionally disabled; the dashboard is the single queue. """ req = get_request_by_token(token) if not req: abort(404) choice = request.form.get('choice') if choice not in ('a', 'b', 'both'): flash('Invalid selection.', 'error') return redirect(url_for('play', token=token)) update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc()) flash('Thanks! Please return to the booth to finalize payment.', 'success') return redirect(url_for('play', token=token)) @app.route('/play//revise', methods=['POST']) def revise(token): """ Customer asked for changes. Enforces the runtime max revisions limit, archives the current A/B MP3 files, stores the revision note, and resets status to 'revisions_requested'. """ note = request.form.get('revision_note', '').strip() req = get_request_by_token(token) if not req: abort(404) # Enforce max revisions limit for customer-submitted revisions. max_revisions = get_max_revisions() current_count = int(req.get('revision_count') or 0) if current_count >= max_revisions: flash('Revision limit reached. Please speak to the booth operator if you need further changes.', 'error') return redirect(url_for('play', token=token)) # Increment revision counter and archive current files before new versions are uploaded. new_count = current_count + 1 upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(req['id']) old_a, old_b = req.get('song_a_path'), req.get('song_b_path') new_a, new_b = old_a, old_b if upload_dir.exists(): for field, version in (('song_a_path', 'A'), ('song_b_path', 'B')): path = req.get(field) if path and Path(path).exists(): old = Path(path) archived = upload_dir / f"Rev{new_count}-{old.name}" try: old.rename(archived) if field == 'song_a_path': new_a = str(archived) else: new_b = str(archived) req[field] = str(archived) except OSError: pass log_revision(req['id'], new_count, note, old_a=old_a, old_b=old_b, new_a=new_a, new_b=new_b) update_request(req['id'], revision_note=note, status='revisions_requested', song_a_path=req.get('song_a_path'), song_b_path=req.get('song_b_path'), customer_approved='none', revision_count=int(new_count)) # NOTE: No operator email is sent; the dashboard is the single queue. flash('Your feedback has been saved. We will regenerate and update you.', 'success') return redirect(url_for('play', token=token)) @app.route('/api/stream//.mp3') def stream_audio(token, version): """ Stream an uploaded MP3 through a backend proxy endpoint. This hides the real file path from the customer. The endpoint checks the player token and serves bytes with Range request support so the HTML audio player can seek. The URL is still interceptable in-browser, but it is not a direct file path and can be gated or expired later. """ req = get_request_by_token(token) if not req: abort(404) if version not in ('a', 'b'): abort(404) # Both versions must exist before any streaming happens. a_path = req.get('song_a_path') b_path = req.get('song_b_path') if not a_path or not b_path: abort(404) for p in (a_path, b_path): if not Path(p).exists(): abort(404) path = a_path if version == 'a' else b_path file_path = Path(path) file_size = file_path.stat().st_size range_header = request.headers.get('Range', '') start = 0 end = file_size - 1 status_code = 200 if range_header and range_header.startswith('bytes='): try: range_value = range_header[len('bytes='):].strip() if '-' in range_value: parts = range_value.split('-') if parts[0]: start = int(parts[0]) if parts[1]: end = min(int(parts[1]), file_size - 1) if start >= file_size or start > end: abort(416) status_code = 206 except ValueError: start = 0 end = file_size - 1 status_code = 200 def generate(): with open(file_path, 'rb') as f: f.seek(start) remaining = end - start + 1 chunk_size = 64 * 1024 while remaining > 0: to_read = min(chunk_size, remaining) data = f.read(to_read) if not data: break yield data remaining -= len(data) response = current_app.response_class(generate(), mimetype='audio/mpeg') response.status_code = status_code response.headers['Accept-Ranges'] = 'bytes' response.headers['Content-Disposition'] = 'inline' response.headers['Content-Length'] = str(end - start + 1) if status_code == 206: response.headers['Content-Range'] = f'bytes {start}-{end}/{file_size}' return response @app.route('/api/audio-source//.mp3') def audio_source(token, version): """ Serve a single MP3 file for stems extraction. Unlike /api/stream (which requires both A and B to exist for the customer player), this endpoint serves whichever version is requested, as long as that file exists. This URL is given to the MusicGPT Extraction API so it can download the source audio for stem separation. """ req = get_request_by_token(token) if not req: abort(404) if version not in ('a', 'b'): abort(404) path = req.get('song_a_path') if version == 'a' else req.get('song_b_path') if not path or not Path(path).exists(): abort(404) file_path = Path(path) file_size = file_path.stat().st_size range_header = request.headers.get('Range', '') start = 0 end = file_size - 1 status_code = 200 if range_header and range_header.startswith('bytes='): try: range_value = range_header[len('bytes='):].strip() if '-' in range_value: parts = range_value.split('-') if parts[0]: start = int(parts[0]) if parts[1]: end = min(int(parts[1]), file_size - 1) if start >= file_size or start > end: abort(416) status_code = 206 except ValueError: start = 0 end = file_size - 1 status_code = 200 def generate(): with open(file_path, 'rb') as f: f.seek(start) remaining = end - start + 1 chunk_size = 64 * 1024 while remaining > 0: to_read = min(chunk_size, remaining) data = f.read(to_read) if not data: break yield data remaining -= len(data) response = current_app.response_class(generate(), mimetype='audio/mpeg') response.status_code = status_code response.headers['Accept-Ranges'] = 'bytes' response.headers['Content-Disposition'] = 'inline' response.headers['Content-Length'] = str(end - start + 1) if status_code == 206: response.headers['Content-Range'] = f'bytes {start}-{end}/{file_size}' return response @app.route('/audio//.mp3') def audio(token, version): """ Legacy audio endpoint. Replaced by /api/stream//.mp3. Returns 404 so old direct links do not work. """ abort(404) @app.route('/admin/request//download-stems') def admin_download_stems(rid): """Download all stems files for a request as a single .zip archive.""" redir = require_admin() if redir: return redir req = get_request_by_id(rid) if not req: abort(404) req = dict(req) stems_url = req.get('stems_url') or '' if not stems_url: flash('No stems available to download.', 'error') return redirect(url_for('admin_request', rid=rid)) # Parse the stems_url field. It can be: # 1. A single bundle/zip URL (use directly) # 2. "label1: url1; label2: url2" (multiple individual stem files) # 3. Just a bare URL urls = [] if ';' in stems_url or ': ' in stems_url: # Multiple stems: "instrumental: https://...; vocals: https://..." parts = stems_url.split(';') for part in parts: part = part.strip() if not part: continue # Split on first ": " to separate label from URL if ': ' in part: label, url = part.split(': ', 1) urls.append((label.strip(), url.strip())) else: urls.append(('stem', part)) else: urls.append(('stems', stems_url.strip())) if not urls: flash('No stems files found.', 'error') return redirect(url_for('admin_request', rid=rid)) # If there's only one URL and it's already a .zip, redirect directly. if len(urls) == 1 and urls[0][1].endswith('.zip'): return redirect(urls[0][1]) # Download each stem file and bundle them into a zip in memory. import requests as req_lib buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: for label, url in urls: try: r = req_lib.get(url, timeout=60) r.raise_for_status() # Derive a filename from the URL or label. filename = url.split('/')[-1] if '/' in url else f"{label}.mp3" # If the URL has no extension, use the label. if '.' not in filename: filename = f"{label}.mp3" zf.writestr(filename, r.content) except Exception: # Skip files that fail to download, but include the rest. pass buf.seek(0) title = req.get('suno_title') or req.get('name') or f'request_{rid}' download_name = f"stems_{title}.zip" return send_file(buf, mimetype='application/zip', as_attachment=True, download_name=download_name) # --------------------------------------------------------------------------- # Admin routes # --------------------------------------------------------------------------- @app.route('/admin/login', methods=['GET', 'POST']) def admin_login(): """Simple session-based admin login. Password is set via ADMIN_PASSWORD env var.""" if is_admin(): return redirect(url_for('admin_dashboard')) if request.method == 'POST': if admin_password_ok(request.form.get('password', '')): session['admin'] = True return redirect(url_for('admin_dashboard')) flash('Invalid password.', 'error') return render_template('admin/login.html') @app.route('/admin/logout') def admin_logout(): """Clear the admin session.""" session.pop('admin', None) return redirect(url_for('admin_login')) @app.route('/admin') def admin_dashboard(): """ Main operator queue. Optional ?status= filter lets operators focus on one state at a time. Auto-refresh interval is controlled from /admin/settings. """ redir = require_admin() if redir: return redir status_filter = request.args.get('status') requests = list_requests(status_filter) return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter, refresh_seconds=get_refresh_seconds()) @app.route('/admin/sales') def admin_sales(): """ Sales report: delivered requests only. Shows customer email, name, chosen version, Square payment reference, and aggregate MusicGPT generation/stems costs in USD. """ redir = require_admin() if redir: return redir sales = list_requests(status='delivered') cost_totals = get_musicgpt_cost_totals() return render_template('admin/sales.html', sales=sales, statuses=STATUS_LABELS, cost_totals=cost_totals) @app.route('/admin/pricing', methods=['GET', 'POST']) def admin_pricing(): """ Pricing configuration page. Fixed items: one_song, both_songs, wav_per_song, stems_per_song. Plus up to 5 custom name/price pairs. """ redir = require_admin() if redir: return redir fixed_keys = ['one_song', 'both_songs', 'wav_per_song', 'stems_per_song'] custom_count = 5 cfg = load_booth_settings() if 'pricing' not in cfg: cfg['pricing'] = {} if request.method == 'POST': pricing = {} for key in fixed_keys: pricing[key] = request.form.get(key, '').strip() for i in range(1, custom_count + 1): name = request.form.get(f'custom_name_{i}', '').strip() price = request.form.get(f'custom_price_{i}', '').strip() if name: pricing[f'custom_{i}'] = {'name': name, 'price': price} else: pricing[f'custom_{i}'] = None cfg['pricing'] = pricing save_booth_settings(cfg) flash('Pricing saved.', 'success') return redirect(url_for('admin_pricing')) pricing = cfg.get('pricing', {}) fixed = {key: pricing.get(key, '') for key in fixed_keys} customs = [] for i in range(1, custom_count + 1): entry = pricing.get(f'custom_{i}') customs.append({ 'name': entry.get('name', '') if isinstance(entry, dict) else '', 'price': entry.get('price', '') if isinstance(entry, dict) else '' }) return render_template('admin/pricing.html', fixed=fixed, customs=customs) @app.route('/kiosk') def kiosk(): """ Public kiosk display page for the booth. Shows open/closed banner, pricing, and optionally cycles with a QR code. Auto-refreshes so pricing updates are picked up quickly. """ cfg = load_booth_settings() pricing = cfg.get('pricing', {}) fixed_keys = { 'one_song': 'One Song', 'both_songs': 'Both Songs', 'wav_per_song': 'WAV files / song', 'stems_per_song': 'STEM files / song' } price_items = [] for key, label in fixed_keys.items(): val = pricing.get(key, '').strip() if val: price_items.append({'label': label, 'price': val}) for i in range(1, 6): entry = pricing.get(f'custom_{i}') if isinstance(entry, dict): name = entry.get('name', '').strip() price = entry.get('price', '').strip() if name and price: price_items.append({'label': name, 'price': price}) cycle_seconds = get_kiosk_cycle_seconds() mode = get_kiosk_mode() booth_open = get_booth_open() # Build the public queue: only active statuses, mapped to friendly names, # using the local-part of the email as the customer name. KIOSK_STATUS_MAP = { 'pending': 'Received', 'prompt_ready': 'Trollgorithm Recording in Studio', 'songs_uploaded': 'Trollgorithm Recording in Studio', 'revisions_requested': 'Waiting for Customer Response', 'awaiting_payment': 'Payment Due', } active_statuses = set(KIOSK_STATUS_MAP.keys()) raw_queue = list_requests() queue = [] for r in raw_queue: if r.get('status') in active_statuses: email = r.get('email') or '' name = email.split('@')[0] if '@' in email else email queue.append({ 'id': r['id'], 'name': name or 'Guest', 'status': KIOSK_STATUS_MAP[r['status']], 'raw_status': r['status'], }) return render_template( 'kiosk.html', booth_open=booth_open, price_items=price_items, cycle_seconds=cycle_seconds, mode=mode, queue=queue, refresh_seconds=30 ) @app.route('/admin/request/', methods=['GET', 'POST']) def admin_request(rid): """ Detail/edit page for a single request. GET -> render customer info (email editable), prompt, upload status, email status, operator notes, and delivery forms. POST -> handle one of six actions: update_customer_email, save_operator_notes, save_prompt, upload_songs, notify_customer, mark_paid_deliver Uploaded MP3s are tagged with metadata defaults from /admin/settings. """ redir = require_admin() if redir: return redir req = get_request_by_id(rid) if not req: abort(404) # Small helpers exposed to the template for status badges. def file_exists(path): return bool(path and Path(path).exists()) def basename(path): return Path(path).name if path else '' # Collect any extra MP3 files in the request folder (archived revisions). upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid) current_paths = {req['song_a_path'], req['song_b_path']} extra_files = [] if upload_dir.exists(): for f in upload_dir.iterdir(): if f.is_file() and f.suffix.lower() == '.mp3' and str(f) not in current_paths: extra_files.append(str(f)) extra_files.sort() if request.method == 'POST': action = request.form.get('action') if action == 'update_customer_email': new_email = request.form.get('email', '').strip().lower() if not is_valid_email(new_email): flash('Please enter a valid email address.', 'error') return redirect(url_for('admin_request', rid=rid)) update_request(rid, email=new_email) flash('Customer email updated.', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'update_customer_info': new_email = request.form.get('email', '').strip().lower() pronouns = request.form.get('pronouns', '').strip() if not is_valid_email(new_email): flash('Please enter a valid email address.', 'error') return redirect(url_for('admin_request', rid=rid)) if not pronouns: flash('Pronouns are required.', 'error') return redirect(url_for('admin_request', rid=rid)) style_genre = build_style_genre( request.form.get('decade', '').strip(), request.form.get('basic_style', '').strip(), request.form.get('additional_style', '').strip() ) update_request(rid, email=new_email, name=request.form.get('name', '').strip(), pronouns=pronouns, hobbies=request.form.get('hobbies', '').strip(), notable_facts=request.form.get('notable_facts', '').strip(), style_genre=style_genre, vocal_gender=request.form.get('vocal_gender', '').strip(), extra_requests=request.form.get('extra_requests', '').strip(), stems_interest=bool(request.form.get('stems_interest')) ) flash('Customer info updated.', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'save_operator_notes': update_request(rid, operator_notes=request.form.get('operator_notes', '').strip()) flash('Operator notes saved.', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'save_stems_link': update_request(rid, stems_link=request.form.get('stems_link', '').strip()) flash('Stems share link saved.', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'reprocess_stems': # Manually trigger Gokapi upload for existing stems (e.g. webhook fired before Gokapi was configured). if not req.get('stems_url'): flash('No stems files to process.', 'error') return redirect(url_for('admin_request', rid=rid)) gokapi_url, gokapi_err = process_stems_to_gokapi(req) if gokapi_err: flash(f'Failed to upload stems to Gokapi: {gokapi_err}', 'error') else: flash(f'Stems uploaded to Gokapi: {gokapi_url}', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'cancel_request': update_request(rid, status='cancelled') flash('Request marked as cancelled.', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'save_prompt': # Store the generated title/style/lyrics and mark prompt ready. update_request(rid, suno_title=request.form.get('suno_title', '').strip(), suno_style=request.form.get('suno_style', '').strip(), suno_lyrics=request.form.get('suno_lyrics', '').strip(), status='prompt_ready' ) flash('Prompt saved.', 'success') elif action == 'generate_musicgpt': # Queue a MusicGPT generation job. api_key = get_musicgpt_api_key() if not api_key: flash('MusicGPT API key is not configured.', 'error') return redirect(url_for('admin_request', rid=rid)) if req.get('musicgpt_status') in ('IN_QUEUE', 'IN_PROGRESS'): flash('A MusicGPT generation is already in progress for this request.', 'error') return redirect(url_for('admin_request', rid=rid)) title = request.form.get('suno_title', '').strip() style = request.form.get('suno_style', '').strip() lyrics = request.form.get('suno_lyrics', '').strip() if not (title and style and lyrics): flash('Title, style, and lyrics are required to generate music.', 'error') return redirect(url_for('admin_request', rid=rid)) model = request.form.get('musicgpt_model', get_musicgpt_default_model()).strip() if model not in get_musicgpt_models(): flash('Invalid MusicGPT model selected.', 'error') return redirect(url_for('admin_request', rid=rid)) gender = request.form.get('vocal_gender', req.get('vocal_gender') or '').strip() task_id, conv1, conv2, estimate, error = musicgpt_generate_request( rid, title, style, lyrics, gender=gender, model=model ) if error: update_request(rid, musicgpt_status='ERROR', musicgpt_error=error, musicgpt_conversion_id_1=conv1, musicgpt_conversion_id_2=conv2) flash(f'Failed to queue MusicGPT generation: {error}', 'error') else: update_request(rid, suno_title=title, suno_style=style, suno_lyrics=lyrics, musicgpt_task_id=task_id, musicgpt_conversion_id_1=conv1, musicgpt_conversion_id_2=conv2, musicgpt_status='IN_QUEUE', musicgpt_error=None, musicgpt_cost=estimate, status='prompt_ready', deliver_wav=1 if request.form.get('deliver_wav') else 0 ) flash(f'MusicGPT generation queued (task {task_id}). Estimated cost: {estimate}', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'save_delivery_options': update_request(rid, deliver_wav=1 if request.form.get('deliver_wav') else 0) flash('Delivery options saved.', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'cancel_musicgpt': # Mark an in-flight MusicGPT job as cancelled locally. if req.get('musicgpt_status') in ('IN_QUEUE', 'IN_PROGRESS'): update_request(rid, musicgpt_status='CANCELLED') flash('MusicGPT generation marked as cancelled.', 'success') else: flash('No in-progress MusicGPT generation to cancel.', 'error') return redirect(url_for('admin_request', rid=rid)) elif action == 'generate_stems': # Queue a stem extraction job for the selected MP3 (Version A or B). # Clear any previous error at the start of every attempt. api_key = get_musicgpt_api_key() if not api_key: flash('MusicGPT API key is not configured.', 'error') return redirect(url_for('admin_request', rid=rid)) if req.get('stems_status') in ('IN_QUEUE', 'IN_PROGRESS'): flash('A stems extraction is already in progress.', 'error') return redirect(url_for('admin_request', rid=rid)) # Clear prior error so the operator doesn't see a stale message after retry. update_request(rid, stems_error=None) # Operator selects which version (A or B) to extract stems from. selected = request.form.get('stems_source', '').strip() if selected == 'a': song_path = req.get('song_a_path') elif selected == 'b': song_path = req.get('song_b_path') else: flash('Please select Version A or Version B for stems extraction.', 'error') return redirect(url_for('admin_request', rid=rid)) if not song_path or not Path(song_path).exists(): flash(f'Version {selected.upper()} MP3 is not available. Generate or upload it first.', 'error') return redirect(url_for('admin_request', rid=rid)) # Upload the MP3 file directly to the Extraction API. # The API's audio_url field only supports YouTube URLs, not direct MP3 links. stems = request.form.getlist('stems') or ['vocals', 'instrumental'] task_id, conv_id, estimate, error = musicgpt_queue_stems(rid, song_path, stems=stems) if error: update_request(rid, stems_status='ERROR', stems_error=error) flash(f'Failed to queue stems extraction: {error}', 'error') else: update_request(rid, stems_task_id=task_id, stems_status='IN_QUEUE', stems_error=None, stems_cost=estimate) flash(f'Stems extraction queued (task {task_id}). Estimated cost: {estimate}', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'delete_songs': # Remove selected uploaded MP3s and reset status so new songs can be uploaded. to_delete = request.form.getlist('delete_song') if not to_delete: flash('Select at least one song to delete.', 'error') return redirect(url_for('admin_request', rid=rid)) update_fields = {} for version in to_delete: if version == 'a': path = req.get('song_a_path') wav = req.get('song_a_wav_path') if path and Path(path).exists(): try: Path(path).unlink() except OSError: pass if wav and Path(wav).exists(): try: Path(wav).unlink() except OSError: pass update_fields['song_a_path'] = None update_fields['song_a_wav_path'] = None elif version == 'b': path = req.get('song_b_path') wav = req.get('song_b_wav_path') if path and Path(path).exists(): try: Path(path).unlink() except OSError: pass if wav and Path(wav).exists(): try: Path(wav).unlink() except OSError: pass update_fields['song_b_path'] = None update_fields['song_b_wav_path'] = None if update_fields: # Wipe any prior customer approval because the old files are gone. update_fields['customer_approved'] = 'none' # Reset to prompt_ready since songs need to be uploaded again. update_fields['status'] = 'prompt_ready' update_request(rid, **update_fields) flash('Selected songs deleted. Request reset to Prompt Ready.', 'success') else: flash('No uploaded songs selected for deletion.', 'error') elif action == 'upload_songs': # Save uploaded MP3 files for Version A and/or Version B. # Use the saved Suno title as the MP3 title tag if available. song_title = req.get('suno_title') or None a_path = save_upload(rid, request.files.get('song_a'), 'a', song_title) b_path = save_upload(rid, request.files.get('song_b'), 'b', song_title) fields = {} if a_path: fields['song_a_path'] = a_path if b_path: fields['song_b_path'] = b_path if fields: fields['status'] = 'songs_uploaded' update_request(rid, **fields) flash('Songs uploaded.', 'success') elif action == 'notify_customer': # Email the customer a private player link. Both songs must be uploaded first. if not (req['song_a_path'] and req['song_b_path']): flash('Both songs must be uploaded first.', 'error') else: player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}" body = f"Hi {req['name']},\n\nYour custom theme song has been created. Listen to both versions and let us know which one you want:\n\n{player_link}\n\n- Version A\n- Version B\n- Or both versions\n\nOnce you make your choice, we'll send you to the booth to finalize payment and deliver your files.\n\nThanks for stopping by!\n\n— {current_app.config['BOOTH_NAME']}" try: send_email(req['email'], 'Your custom theme song is ready — listen and pick your version', body, inline_images=build_signature_images()) update_request(rid, preview_sent_at=now_utc(), status='songs_uploaded') flash('Preview email sent.', 'success') except Exception as e: flash(f'Failed to send preview email: {e}', 'error') elif action == 'update_payment_ref': # Update the Square payment reference without sending email or changing status. payment_ref = request.form.get('square_payment_ref', '').strip() update_request(rid, square_payment_ref=payment_ref) flash('Payment reference updated.', 'success') return redirect(url_for('admin_request', rid=rid)) elif action == 'mark_paid_deliver': # Finalize: record Square payment ref, attach approved MP3s, email customer. # Also capture the deliver_wav checkbox from the same form. update_request(rid, deliver_wav=1 if request.form.get('deliver_wav') else 0) if req.get('customer_approved', 'none') == 'none': flash('Customer must approve a version before you can mark paid or deliver.', 'error') return redirect(url_for('admin_request', rid=rid)) payment_ref = request.form.get('square_payment_ref', '').strip() if not payment_ref: flash('Square payment reference is required.', 'error') return redirect(url_for('admin_request', rid=rid)) # Build list of selected files from checkboxes. selected = request.form.getlist('deliver_file') if not selected: flash('Select at least one file to deliver.', 'error') return redirect(url_for('admin_request', rid=rid)) # Record payment immediately so the reference is preserved even if email fails. update_request(rid, square_payment_ref=payment_ref, status='delivered') attachments = [] for path in selected: p = Path(path) if p.exists(): attachments.append((str(p), p.name)) # Compute 3-month expiry date for any stems share link. from datetime import datetime, timedelta expiry_date = (datetime.utcnow() + timedelta(days=90)).strftime('%B %d, %Y') player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}" # Format lyrics the same way they appear on the customer player page: # skip section headers like [Chorus] and blank lines, keep stanza breaks. lyrics_section = '' if req.get('suno_lyrics'): lyric_lines = [] for line in req['suno_lyrics'].splitlines(): stripped = line.strip() if stripped and not (stripped.startswith('[') and stripped.endswith(']')): lyric_lines.append(stripped) elif not stripped and lyric_lines and lyric_lines[-1] != '': lyric_lines.append('') if lyric_lines: lyrics_section = '\n'.join(['', '---', 'Song Lyrics', '---', ''] + lyric_lines + ['']) body_lines = [ f"Hi {req['name']},", "", "Thanks for your payment! Your selected song(s) are attached to this email.", f"You can also keep streaming them here: {player_link}", ] if lyrics_section: body_lines.append(lyrics_section) body_lines += [ "", "Enjoy!", "", f"— {current_app.config['BOOTH_NAME']}" ] if req.get('stems_link'): body_lines.insert(4, f"Your stems / extras are available here: {req['stems_link']}") body_lines.insert(5, f"This share link expires on {expiry_date} (3 months from today). Please download before then.") body_lines.insert(6, "") body = '\n'.join(body_lines) # Optionally attach WAV files and album cover. if req.get('deliver_wav'): for wav_field in ('song_a_wav_path', 'song_b_wav_path'): wav_path = req.get(wav_field) if wav_path and Path(wav_path).exists(): p = Path(wav_path) attachments.append((str(p), p.name)) cfg = load_booth_settings() if cfg.get('deliver_album_cover') and req.get('album_cover_url'): cover_path = download_album_cover(rid, req['album_cover_url'], max_width=160) if cover_path: attachments.append((cover_path, f"cover{Path(cover_path).suffix}")) try: send_email(req['email'], 'Your theme song files are here!', body, attachments=attachments, inline_images=build_signature_images()) update_request(rid, delivery_sent_at=now_utc()) flash('Delivery email sent with MP3 attachments.', 'success') except Exception as e: flash(f'Payment recorded, but delivery email failed: {e}', 'error') steps, current_step = get_request_steps(req) musicgpt_status = req.get('musicgpt_status') return redirect(url_for('admin_request', rid=rid)) steps, current_step = get_request_steps(req) musicgpt_status = req.get('musicgpt_status') return render_template( 'admin/request.html', req=req, style_parts=parse_style_genre(req.get('style_genre')), decades=DECADES, genres=MUSIC_GENRES, statuses=STATUS_LABELS, file_exists=file_exists, basename=basename, extra_files=extra_files, callback_url=build_prompt_callback_url(rid), revision_history=list_revision_history(rid), musicgpt_models=get_musicgpt_models(), musicgpt_default_model=get_musicgpt_default_model(), format_musicgpt_cost=format_musicgpt_cost, deliver_album_cover=load_booth_settings().get('deliver_album_cover', False), steps=steps, current_step=current_step, musicgpt_status=musicgpt_status, ) @app.route('/admin/request//delete', methods=['POST']) def admin_delete_request(rid): """Delete a single request and remove its uploaded MP3 files.""" redir = require_admin() if redir: return redir req = get_request_by_id(rid) if not req: abort(404) # Delete uploaded files if they exist. for field in ('song_a_path', 'song_b_path'): path = req.get(field) if path and Path(path).exists(): try: Path(path).unlink() except OSError: pass # Remove empty upload directory. upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid) if upload_dir.exists(): try: upload_dir.rmdir() except OSError: pass delete_request(rid) flash(f'Request #{rid} deleted.', 'success') return redirect(url_for('admin_dashboard')) @app.route('/admin/settings', methods=['GET', 'POST']) def admin_settings(): """ Settings / maintenance page for operators. GET -> show database health, statistics, disk usage, runtime settings forms, booth open/closed switch, SMTP/email config, MP3 metadata defaults, backup/restore, and reset. POST -> handle one of: fix_db, reset_system, save_max_revisions, save_metadata, save_email_config, send_test_email, save_refresh, save_booth_open, download_db, restore_db. """ redir = require_admin() if redir: return redir db_path = Path(current_app.config['DATABASE']) upload_root = Path(current_app.config['UPLOAD_FOLDER']) # Compute database stats. db_size = db_path.stat().st_size if db_path.exists() else 0 all_requests = list_requests() total_records = len(all_requests) status_counts = {} for req in all_requests: status_counts[req['status']] = status_counts.get(req['status'], 0) + 1 # Load persistent runtime settings (max_revisions overrides env var if set). runtime_settings = load_booth_settings() current_max_revisions = get_max_revisions() current_refresh_seconds = runtime_settings.get('refresh_seconds', 10) booth_open = runtime_settings.get('booth_open', True) musicgpt_autopoll = runtime_settings.get('musicgpt_autopoll', True) # Effective email config to show in the form (non-sensitive only; password left blank). email_form = { 'smtp_host': runtime_settings.get('smtp_host', current_app.config['SMTP_HOST']), 'smtp_port': runtime_settings.get('smtp_port', str(current_app.config['SMTP_PORT'])), 'smtp_user': runtime_settings.get('smtp_user', current_app.config['SMTP_USER']), 'smtp_from': runtime_settings.get('smtp_from', current_app.config['SMTP_FROM']), 'smtp_pass_set': bool(runtime_settings.get('smtp_pass', '')), } # Hermes API key state for the settings page. hermes_key = get_hermes_api_key() hermes_key_masked = mask_api_key(hermes_key) hermes_key_set = bool(hermes_key) hermes_key_just_generated = session.pop('hermes_key_just_generated', None) # Compute upload folder stats. total_upload_size = 0 upload_file_count = 0 request_dir_count = 0 if upload_root.exists(): for entry in upload_root.iterdir(): if entry.is_dir(): request_dir_count += 1 for f in entry.iterdir(): if f.is_file(): total_upload_size += f.stat().st_size upload_file_count += 1 elif entry.is_file(): total_upload_size += entry.stat().st_size upload_file_count += 1 def format_bytes(n): for unit in ['B', 'KB', 'MB', 'GB']: if n < 1024: return f"{n:.2f} {unit}" n /= 1024 return f"{n:.2f} TB" # Health check: verify expected columns exist and expected tables exist. expected_cols = { 'id', 'created_at', 'name', 'email', 'hobbies', 'notable_facts', 'style_genre', 'extra_requests', 'vocal_gender', 'status', 'suno_title', 'suno_style', 'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved', 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count', 'operator_notes', 'stems_link', 'stems_interest', 'musicgpt_task_id', 'musicgpt_conversion_id_1', 'musicgpt_conversion_id_2', 'musicgpt_status', 'musicgpt_cost', 'musicgpt_cost_a', 'musicgpt_cost_b', 'musicgpt_error', 'album_cover_url', 'song_a_wav_path', 'song_b_wav_path', 'deliver_wav', 'stems_task_id', 'stems_status', 'stems_cost', 'stems_url', 'stems_error' } expected_tables = {'requests', 'revision_history'} health = {'ok': True, 'missing_columns': [], 'missing_tables': [], 'message': 'Database schema looks good.'} try: db = get_db() cur = db.execute("SELECT name FROM sqlite_master WHERE type='table'") existing_tables = {row['name'] for row in cur.fetchall()} missing_tables = sorted(expected_tables - existing_tables) cur = db.execute('PRAGMA table_info(requests)') existing_cols = {row['name'] for row in cur.fetchall()} missing_cols = sorted(expected_cols - existing_cols) if missing_tables or missing_cols: parts = [] if missing_tables: parts.append(f"missing tables: {', '.join(missing_tables)}") if missing_cols: parts.append(f"missing columns: {', '.join(missing_cols)}") health = {'ok': False, 'missing_columns': missing_cols, 'missing_tables': missing_tables, 'message': 'Database schema issues: ' + '; '.join(parts)} except Exception as e: health = {'ok': False, 'missing_columns': [], 'missing_tables': [], 'message': f'Could not inspect database: {e}'} if request.method == 'POST': action = request.form.get('action') if action == 'fix_db': # Attempt to create missing tables and add missing columns via ALTER TABLE. try: db = get_db() db.executescript(SCHEMA) if not health['ok'] and health['missing_columns']: for col in health['missing_columns']: # Default to TEXT columns; adequate for current schema. db.execute(f'ALTER TABLE requests ADD COLUMN {col} TEXT') flash(f'Created missing tables and added columns: {", ".join(health["missing_columns"])}. Please refresh the page.', 'success') else: flash('Database schema is up to date.', 'success') db.commit() except Exception as e: flash(f'Failed to fix database: {e}', 'error') return redirect(url_for('admin_settings')) elif action == 'reset_system': # Same nuclear reset logic as the old /admin/reset endpoint. if upload_root.exists(): for entry in upload_root.iterdir(): try: if entry.is_file(): entry.unlink() elif entry.is_dir(): shutil.rmtree(entry) except OSError: pass reset_all_requests() flash('System reset complete. All orders and files have been cleared.', 'success') return redirect(url_for('admin_settings')) elif action == 'save_max_revisions': # Update the MAX_REVISIONS config from the settings form. try: val = int(request.form.get('max_revisions', '2').strip()) if val < 0: raise ValueError cfg = load_booth_settings() cfg['max_revisions'] = val save_booth_settings(cfg) flash(f'Maximum revisions set to {val}.', 'success') except ValueError: flash('Invalid revision limit. Please enter a non-negative number.', 'error') return redirect(url_for('admin_settings')) elif action == 'save_metadata': # Update MP3 metadata defaults from the settings form. # Store empty strings (not None) so fields repopulate correctly on reload. cfg = load_booth_settings() for key in ('artist', 'album', 'year', 'comment'): cfg[key] = request.form.get(key, '').strip() save_booth_settings(cfg) flash('MP3 metadata defaults saved.', 'success') return redirect(url_for('admin_settings')) elif action == 'save_email_config': # Update SMTP settings from the settings form. Password is encrypted. # Empty values are stored as empty strings so the form repopulates. cfg = load_booth_settings() cfg['smtp_host'] = request.form.get('smtp_host', '').strip() cfg['smtp_port'] = request.form.get('smtp_port', '').strip() cfg['smtp_user'] = request.form.get('smtp_user', '').strip() cfg['smtp_from'] = request.form.get('smtp_from', '').strip() new_pass = request.form.get('smtp_pass', '').strip() # Only overwrite the stored password if a new value was provided. if new_pass: cfg['smtp_pass'] = encrypt_value(new_pass) save_booth_settings(cfg) flash('Email (SMTP) settings saved. Password stored encrypted.', 'success') return redirect(url_for('admin_settings')) elif action == 'send_test_email': # Send a test email to the address provided in the form. test_to = request.form.get('test_email_address', '').strip() if not test_to: flash('Enter a test email address first.', 'error') return redirect(url_for('admin_settings')) try: body = "Hi,\n\nThis is a test email from the Trollgorithm Theme Song Booth. If you're seeing this, SMTP is configured correctly." send_email(test_to, 'SMTP Test from Theme Song Booth', body, inline_images=build_signature_images()) flash(f'Test email sent to {test_to}.', 'success') except Exception as e: flash(f'Failed to send test email: {e}', 'error') return redirect(url_for('admin_settings')) elif action == 'save_refresh': # Update dashboard auto-refresh interval. val = request.form.get('refresh_seconds', '10').strip() if val not in ('0', '10', '20', '30'): val = '10' cfg = load_booth_settings() cfg['refresh_seconds'] = int(val) save_booth_settings(cfg) flash(f'Dashboard auto-refresh set to {val} seconds.', 'success') return redirect(url_for('admin_settings')) elif action == 'save_kiosk_cycle': # Update kiosk slide cycle interval. Special values: # -1 = show QR only, 0 = show pricing only, 5+ = cycle every N seconds. raw = request.form.get('kiosk_cycle_seconds', '10').strip() try: val = int(raw) except ValueError: val = 10 if val not in (-1, 0) and val < 5: val = 5 cfg = load_booth_settings() cfg['kiosk_cycle_seconds'] = val save_booth_settings(cfg) if val == -1: label = 'QR code only' elif val == 0: label = 'pricing only' else: label = f'cycle every {val} seconds' flash(f'Kiosk mode set to {label}.', 'success') return redirect(url_for('admin_settings')) elif action == 'save_booth_open': # Toggle whether the public request form is accepting submissions. cfg = load_booth_settings() cfg['booth_open'] = request.form.get('booth_open', '1') == '1' save_booth_settings(cfg) state = 'open' if cfg['booth_open'] else 'closed' flash(f'Booth is now {state}.', 'success') return redirect(url_for('admin_settings')) elif action == 'download_db': # Send the SQLite database file as a download. if db_path.exists(): return send_file(str(db_path), as_attachment=True, download_name='theme-song-booth.db') flash('Database file not found.', 'error') return redirect(url_for('admin_settings')) elif action == 'download_uploads_zip': # Zip all files under UPLOAD_FOLDER and send as a download. import zipfile zip_path = db_path.with_suffix('.uploads-' + str(int(time.time())) + '.zip') try: with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: if upload_root.exists(): for entry in upload_root.rglob('*'): if entry.is_file(): zf.write(str(entry), str(entry.relative_to(upload_root))) return send_file(str(zip_path), as_attachment=True, download_name='theme-song-booth-uploads.zip') except Exception as e: flash(f'Failed to create uploads ZIP: {e}', 'error') return redirect(url_for('admin_settings')) finally: if zip_path.exists(): zip_path.unlink() elif action == 'restore_db': # Replace the current database file with an uploaded SQLite backup. file_obj = request.files.get('db_backup') if not file_obj or file_obj.filename == '': flash('No database backup file selected.', 'error') return redirect(url_for('admin_settings')) backup_path = db_path.with_suffix('.backup-restore') try: # Stream uploaded file directly to disk to avoid memory issues with large DBs. file_obj.save(backup_path) # Quick sanity check: try to open as SQLite and query sqlite_master. import sqlite3 conn = sqlite3.connect(str(backup_path)) conn.execute("SELECT name FROM sqlite_master WHERE type='table'") conn.close() # Replace old database with backup. old_backup = db_path.with_suffix('.backup-' + str(int(time.time()))) db_path.rename(old_backup) backup_path.rename(db_path) flash('Database restored successfully. Old database kept at ' + old_backup.name, 'success') except Exception as e: if backup_path.exists(): backup_path.unlink() flash(f'Database restore failed: {e}', 'error') return redirect(url_for('admin_settings')) elif action == 'save_callback_expiry': # Update the Hermes callback URL expiry lifetime in hours. try: val = int(request.form.get('callback_expiry_hours', '168').strip()) if val < 1: raise ValueError cfg = load_booth_settings() cfg['callback_expiry_hours'] = val save_booth_settings(cfg) flash(f'Callback link expiry set to {val} hour(s).', 'success') except ValueError: flash('Invalid callback expiry. Please enter a positive number of hours.', 'error') return redirect(url_for('admin_settings')) elif action == 'regenerate_hermes_key': # Legacy action: no longer exposed in UI. Key is managed via HERMES_API_KEY env var. flash('Hermes API key is managed via the HERMES_API_KEY environment variable.', 'info') return redirect(url_for('admin_settings')) elif action == 'save_ntfy': # Update ntfy push notification server/topic/access token from the settings form. cfg = load_booth_settings() cfg['ntfy_server'] = request.form.get('ntfy_server', '').strip().rstrip('/') cfg['ntfy_topic'] = request.form.get('ntfy_topic', '').strip() new_token = request.form.get('ntfy_token', '').strip() if new_token: cfg['ntfy_token'] = encrypt_value(new_token) save_booth_settings(cfg) flash('ntfy notification settings saved.', 'success') return redirect(url_for('admin_settings')) elif action == 'send_test_ntfy': # Send a test push notification to the configured ntfy topic. ntfy = get_ntfy_config() if not ntfy['server'] or not ntfy['topic']: flash('Configure ntfy server and topic first.', 'error') return redirect(url_for('admin_settings')) ok = send_ntfy('This is a test push notification from the Trollgorithm Theme Song Booth.', title='Test Notification', priority='high', tags='test_tube') if ok: flash('Test ntfy notification sent.', 'success') else: flash('Failed to send ntfy test notification. Check server, topic, and access token.', 'error') return redirect(url_for('admin_settings')) elif action == 'save_album_cover': cfg = load_booth_settings() cfg['deliver_album_cover'] = request.form.get('deliver_album_cover', '0') == '1' save_booth_settings(cfg) flash('Album cover delivery setting saved.', 'success') return redirect(url_for('admin_settings')) elif action == 'save_musicgpt_autopoll': cfg = load_booth_settings() cfg['musicgpt_autopoll'] = request.form.get('musicgpt_autopoll', '0') == '1' save_booth_settings(cfg) state = 'enabled' if cfg['musicgpt_autopoll'] else 'disabled' flash(f'Automatic MusicGPT polling {state}.', 'success') return redirect(url_for('admin_settings')) return render_template( 'admin/settings.html', health=health, db_size=format_bytes(db_size), total_records=total_records, status_counts=status_counts, statuses=STATUS_LABELS, upload_file_count=upload_file_count, upload_dir_count=request_dir_count, upload_size=format_bytes(total_upload_size), db_path=str(db_path), upload_path=str(upload_root), current_max_revisions=current_max_revisions, current_refresh_seconds=current_refresh_seconds, current_kiosk_cycle_seconds=get_kiosk_cycle_seconds(), booth_open=booth_open, email_form=email_form, metadata={ 'artist': runtime_settings.get('artist', ''), 'album': runtime_settings.get('album', ''), 'year': runtime_settings.get('year', ''), 'comment': runtime_settings.get('comment', ''), }, hermes_key_masked=hermes_key_masked, hermes_key_set=hermes_key_set, hermes_key_just_generated=hermes_key_just_generated, version=current_app.config['VERSION'], current_callback_expiry_hours=get_callback_expiry_hours(), ntfy=runtime_settings, deliver_album_cover=runtime_settings.get('deliver_album_cover', False), musicgpt_api_key_set=bool(get_musicgpt_api_key()), musicgpt_webhook_url=build_musicgpt_webhook_url(), musicgpt_autopoll=runtime_settings.get('musicgpt_autopoll', True), ) @app.route('/admin/reset', methods=['POST']) def admin_reset_system(): """ Nuclear reset for the start of an event. Deletes all database rows and all files/directories under UPLOAD_FOLDER. Requires clicking through a browser confirm dialog. """ redir = require_admin() if redir: return redir upload_root = Path(current_app.config['UPLOAD_FOLDER']) if upload_root.exists(): for entry in upload_root.iterdir(): try: if entry.is_file(): entry.unlink() elif entry.is_dir(): shutil.rmtree(entry) except OSError: pass reset_all_requests() flash('System reset complete. All orders and files have been cleared.', 'success') return redirect(url_for('admin_dashboard')) # --------------------------------------------------------------------------- # CLI and entry point # --------------------------------------------------------------------------- @app.cli.command('init-db') def init_db_command(): """Flask CLI command: flask --app app init-db""" init_db() print('Database initialized.') if __name__ == '__main__': # Development-only entry point. Production uses gunicorn (see Dockerfile). app.run(debug=True, host='0.0.0.0') # Ensure the database file and expected tables exist when the app is imported by # gunicorn in production. init_db() uses CREATE TABLE IF NOT EXISTS, so this is # safe to run on every startup without wiping data. with app.app_context(): try: init_db() except Exception: # If the database path is not yet reachable (e.g. volume not mounted), # defer to the first request or the explicit init-db command. import logging logging.getLogger('app').warning('Startup init_db() failed; database may need manual initialization.', exc_info=True)