diff --git a/README.md b/README.md index 880d7f1..bfa8d5c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Theme Song Booth -**Version:** `v0.5.9` +**Version:** `v0.6.0` A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate Suno prompts, upload MP3 previews, collect payment, and deliver final songs by email. diff --git a/VERSION b/VERSION index 416bfb0..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.9 +0.6.0 diff --git a/app.py b/app.py index 20f9324..a3d5e4f 100644 --- a/app.py +++ b/app.py @@ -30,95 +30,36 @@ Admin routes: # Standard library imports import os -import re import shutil -import smtplib -import ssl import time -from email.message import EmailMessage from pathlib import Path -import json - -from cryptography.fernet import Fernet -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -import base64 - -import hmac -import hashlib -import secrets - # Flask and related imports -from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file, jsonify +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 -from werkzeug.utils import secure_filename # 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 -# Audio metadata import -from mutagen.mp3 import MP3 -from mutagen.easyid3 import EasyID3 - -# HTTP client for ntfy push notifications -import requests - - -# --------------------------------------------------------------------------- -# Genre / decade helpers -# --------------------------------------------------------------------------- - -# Load external lists once at import time. These are shared by the customer -# request form and the admin request editor so the dropdowns stay in sync. -# In production the canonical files live under /mnt/Storage. We also bundle -# copies in the repo so the container still works if that mount is missing. -_GENRES_PATH = Path('/mnt/Storage/Music Genres.txt') -_DECADES_PATH = Path('/mnt/Storage/Decades.txt') -_FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres.txt' -_FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt' - - -def _load_lines(path: Path) -> list[str]: - """Load a text file and return non-empty stripped lines.""" - if not path.exists(): - return [] - lines = path.read_text(encoding='utf-8').splitlines() - return [line.strip() for line in lines if line.strip()] - - -def _load_list(primary: Path, fallback: Path) -> list[str]: - """Load from the primary path, falling back to the bundled copy.""" - lines = _load_lines(primary) - if lines: - return lines - return _load_lines(fallback) - - -MUSIC_GENRES = _load_list(_GENRES_PATH, _FALLBACK_GENRES_PATH) -DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH) - - -def parse_style_genre(style_genre: str | None) -> dict: - """ - Split a stored combined style string into decade, basic, and additional. - The stored format is 'Decade, Basic, Additional' (additional may be empty). - """ - parts = [p.strip() for p in (style_genre or '').split(',') if p.strip()] - return { - 'decade': parts[0] if len(parts) > 0 else '', - 'basic_style': parts[1] if len(parts) > 1 else '', - 'additional_style': ', '.join(parts[2:]) if len(parts) > 2 else '', - } - - -def build_style_genre(decade: str, basic_style: str, additional_style: str) -> str: - """Build the combined style_genre string stored in the database.""" - parts = [p.strip() for p in [decade, basic_style, additional_style] if p.strip()] - return ', '.join(parts) +# 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_ntfy_config, send_ntfy, + sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url, + send_email, build_signature_images, + get_booth_open, +) # --------------------------------------------------------------------------- @@ -147,403 +88,6 @@ STATUS_LABELS = { 'cancelled': 'Cancelled', } -# --------------------------------------------------------------------------- -# Helper functions -# --------------------------------------------------------------------------- - -def is_admin(): - """Return True if the current browser session is logged in as admin.""" - return session.get('admin') is True - - -def require_admin(): - """Redirect to the admin login page if the user is not logged in.""" - if not is_admin(): - return redirect(url_for('admin_login')) - - -def admin_password_ok(pw): - """Check the submitted admin password against the configured one.""" - return pw and pw == current_app.config['ADMIN_PASSWORD'] - - -def allowed_file(filename): - """Return True if the uploaded filename has an allowed extension (mp3).""" - return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] - - -def upload_path(request_id): - """Return the per-request upload directory path, creating it if necessary.""" - p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id) - p.mkdir(parents=True, exist_ok=True) - return p - - -def save_upload(request_id, file_obj, version, song_title=None): - """ - Save an uploaded MP3 file for a request, preserving the original filename - with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3). - Applies the configured metadata tags (title, artist, album, year, comment). - """ - if not file_obj or file_obj.filename == '': - return None - if not allowed_file(file_obj.filename): - flash('Only MP3 files are allowed.', 'error') - return None - original = secure_filename(file_obj.filename) - filename = f"{version.upper()} - {original}" - p = upload_path(request_id) - dest = p / filename - file_obj.save(dest) - apply_mp3_tags(str(dest), song_title) - return str(dest) - - -def apply_mp3_tags(path, title=None): - """ - Write common ID3 tags on an uploaded MP3 using the runtime metadata defaults. - Writes title, artist, album, and date via EasyID3, plus a comment using both - a COMM frame and a TXXX:Comment frame for broad reader compatibility. - Failures are logged as a warning and do not block the upload. - """ - cfg = load_booth_settings() - try: - audio = MP3(path) - if audio.tags is None: - audio.add_tags() - if not isinstance(audio.tags, EasyID3): - audio.tags = EasyID3() - tags = audio.tags - if title: - tags['title'] = title - if cfg.get('artist'): - tags['artist'] = cfg['artist'] - if cfg.get('album'): - tags['album'] = cfg['album'] - if cfg.get('year'): - tags['date'] = str(cfg['year']) - audio.save() - if cfg.get('comment'): - from mutagen.id3 import COMM, TXXX - audio2 = MP3(path) - if audio2.tags is None: - audio2.add_tags() - audio2.tags["COMM"] = COMM(encoding=3, lang='eng', desc='Comment', text=cfg['comment']) - audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment']) - audio2.save() - except Exception as e: - try: - flash(f'Warning: could not tag MP3: {e}', 'error') - except RuntimeError: - import logging - logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e) - - -def _get_fernet(): - """Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted.""" - secret = current_app.config['SECRET_KEY'].encode() - kdf = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=b'theme-song-booth-v1', - iterations=480000, - ) - key = base64.urlsafe_b64encode(kdf.derive(secret)) - return Fernet(key) - - -def encrypt_value(value): - """Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext.""" - if not value: - return '' - return _get_fernet().encrypt(value.encode()).decode() - - -def decrypt_value(ciphertext): - """Decrypt a string previously encrypted by encrypt_value.""" - if not ciphertext: - return '' - try: - return _get_fernet().decrypt(ciphertext.encode()).decode() - except Exception: - return '' - - -def decrypt_value_legacy(ciphertext): - """Decrypt or return plaintext. Tolerates unencrypted legacy values.""" - if not ciphertext: - return '' - plaintext = decrypt_value(ciphertext) - if plaintext: - return plaintext - # If decryption failed, the value might already be plaintext. - # A Fernet token is base64 and ends with '='; a plain API key does not. - if not ciphertext.endswith('='): - return ciphertext - return '' - - -def settings_file_path(): - """Return the path to the persistent runtime settings JSON file.""" - return Path(current_app.config['DATABASE']).parent / current_app.config['SETTINGS_FILE'] - - -def load_booth_settings(): - """Load persistent runtime settings from JSON file inside the upload parent.""" - cfg_path = settings_file_path() - if cfg_path.exists(): - try: - cfg = json.loads(cfg_path.read_text()) - # Normalize any legacy None metadata/email values to empty strings - # so form fields repopulate correctly after reload. - for key in ('artist', 'album', 'year', 'comment', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from'): - if cfg.get(key) is None: - cfg[key] = '' - return cfg - except (json.JSONDecodeError, OSError): - pass - return {} - - -def save_booth_settings(settings): - """Persist runtime settings to JSON file.""" - cfg_path = settings_file_path() - try: - cfg_path.write_text(json.dumps(settings, indent=2)) - except OSError as e: - flash(f'Warning: could not save settings: {e}', 'error') - - -def get_email_config(): - """ - Return the effective SMTP configuration. - Runtime settings in booth_settings.json override environment defaults. - The SMTP password is decrypted from the encrypted value stored on disk. - """ - cfg = load_booth_settings() - return { - 'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']), - 'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']), - 'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']), - 'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'], - 'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']), - } - - -def get_refresh_seconds(): - """Return the dashboard auto-refresh interval in seconds (10, 20, or 30).""" - cfg = load_booth_settings() - try: - val = int(cfg.get('refresh_seconds', 10)) - except (ValueError, TypeError): - val = 10 - return val if val in (10, 20, 30) else 10 - - -def get_kiosk_cycle_seconds(): - """Return the kiosk slide cycle interval in seconds. 0 = static price list; 5+ cycles QR and pricing.""" - cfg = load_booth_settings() - try: - val = int(cfg.get('kiosk_cycle_seconds', 10)) - except (ValueError, TypeError): - val = 10 - if val == 0: - return 0 - return max(5, val) - - -def get_kiosk_mode(): - """ - Return 'qr', 'prices', 'queue', or 'cycle' based on kiosk_cycle_seconds setting. - -1 = QR only, 0 = prices only, 1 = queue only, 5+ = cycle through all three. - """ - cfg = load_booth_settings() - try: - val = int(cfg.get('kiosk_cycle_seconds', 10)) - except (ValueError, TypeError): - val = 10 - if val == -1: - return 'qr' - if val == 0: - return 'prices' - if val == 1: - return 'queue' - return 'cycle' - - -def get_max_revisions(): - """Return the effective max revisions as an integer.""" - cfg = load_booth_settings() - try: - val = int(cfg.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2))) - except (ValueError, TypeError): - val = current_app.config.get('MAX_REVISIONS', 2) - return max(0, val) - - -def get_hermes_api_key(): - """ - Return the effective Hermes API key. - Environment variable HERMES_API_KEY overrides any runtime setting. - """ - env_key = current_app.config.get('HERMES_API_KEY', '') - if env_key: - return env_key - cfg = load_booth_settings() - return decrypt_value_legacy(cfg.get('hermes_api_key', '')) - - -def set_hermes_api_key(key): - """Persist a new Hermes API key (encrypted) to runtime settings.""" - cfg = load_booth_settings() - cfg['hermes_api_key'] = encrypt_value(key) - save_booth_settings(cfg) - - -def generate_hermes_api_key(): - """Generate a new random API key for Hermes callback authentication.""" - return secrets.token_urlsafe(32) - - -def mask_api_key(key): - """Return a masked version of the API key showing only the last 6 characters.""" - if not key: - return 'Not set' - if len(key) <= 6: - return '*' * len(key) - return '*' * (len(key) - 6) + key[-6:] - - -def get_ntfy_config(): - """Return the effective ntfy server URL, topic, and access token from runtime settings.""" - cfg = load_booth_settings() - return { - 'server': cfg.get('ntfy_server', ''), - 'topic': cfg.get('ntfy_topic', ''), - 'token': decrypt_value(cfg.get('ntfy_token', '')) or '', - } - - -def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'): - """Send a push notification to the configured ntfy topic, if configured.""" - ntfy = get_ntfy_config() - server = ntfy.get('server', '').rstrip('/') - topic = ntfy.get('topic', '').strip() - if not server or not topic: - return False - - url = f"{server}/{topic}" - headers = { - 'Title': title, - 'Priority': priority, - 'Tags': tags, - } - token = ntfy.get('token', '') - if token: - headers['Authorization'] = f'Bearer {token}' - try: - resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10) - return resp.status_code in (200, 202) - except Exception: - return False - - -def sign_prompt_callback(rid, expires_at=None): - """ - Create a signed callback token for a specific request ID. - The signature is HMAC-SHA256 over "rid:expires_at" using APP_SECRET_KEY. - Returns a URL-safe token string. - """ - secret = current_app.config['SECRET_KEY'].encode() - if expires_at is None: - # Default expiry: 7 days, so operators have plenty of time to copy the - # callback URL into Hermes and for Hermes to POST back. - expires_at = int(time.time()) + 7 * 24 * 3600 - payload = f"{rid}:{expires_at}" - sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:16] - return f"{rid}:{expires_at}:{sig}" - - -def verify_prompt_callback(token): - """ - Verify a signed callback token. Returns (rid, ok) tuple. - The token must match the HMAC signature and not be expired. - """ - if not token: - return None, False - parts = token.split(':') - if len(parts) != 3: - return None, False - try: - rid = int(parts[0]) - expires_at = int(parts[1]) - except ValueError: - return None, False - if int(time.time()) > expires_at: - return None, False - expected = sign_prompt_callback(rid, expires_at) - if not hmac.compare_digest(expected, token): - return None, False - return rid, True - - -def build_prompt_callback_url(rid): - """Build the full callback URL an operator pastes into Hermes for a request.""" - token = sign_prompt_callback(rid) - return f"{current_app.config['PUBLIC_BASE_URL']}/api/prompt/{rid}?token={token}" - - -def send_email(to, subject, body, attachments=None, inline_images=None): - """Send an email using the configured or runtime SMTP settings.""" - cfg = get_email_config() - if not cfg['SMTP_PASS']: - raise RuntimeError('SMTP password is not configured') - - msg = EmailMessage() - msg['From'] = cfg['SMTP_FROM'] - msg['To'] = to - msg['Subject'] = subject - msg.set_content(body) - - html_body = body.replace('\n', '
\n') - if inline_images: - for _, cid in inline_images: - html_body += f'
Dionysis Media' - html_body += f'


Dionysis Media: stories, sound, and a little divine chaos — https://dionysismedia.ca/

' - msg.add_alternative(html_body, subtype='html') - - if inline_images: - for path, cid in inline_images: - with open(path, 'rb') as f: - data = f.read() - ext = Path(path).suffix.lower().lstrip('.') - subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png' - msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>') - - if attachments: - for path, name in attachments: - with open(path, 'rb') as f: - data = f.read() - msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name) - - with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server: - server.login(cfg['SMTP_USER'], cfg['SMTP_PASS']) - server.send_message(msg) - -def build_signature_images(): - """Return inline image tuple list for static/DM-Logo_email.png (Dionysis Media logo).""" - logo_path = Path(current_app.root_path) / 'static' / 'DM-Logo_email.png' - if not logo_path.exists(): - return [] - return [(str(logo_path), 'dm-logo')] - - -def get_booth_open(): - """Return True if the booth is currently marked as open in runtime settings.""" - cfg = load_booth_settings() - return cfg.get('booth_open', True) - - # --------------------------------------------------------------------------- # Public customer routes # --------------------------------------------------------------------------- @@ -585,11 +129,12 @@ def request_form(): 'name': request.form.get('name', '').strip(), 'email': request.form.get('email', '').strip().lower(), 'pronouns': pronouns, - 'hobbies': request.form.get('hobbies', '').strip(), - 'notable_facts': request.form.get('notable_facts', '').strip(), + '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(), + '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') @@ -626,6 +171,7 @@ def request_form(): 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.", "", @@ -1174,7 +720,8 @@ def admin_request(rid): 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() + 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)) @@ -1270,6 +817,13 @@ def admin_request(rid): 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. if req.get('customer_approved', 'none') == 'none': @@ -1464,7 +1018,7 @@ def admin_settings(): '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' + 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count', 'operator_notes', 'stems_link', 'stems_interest' } expected_tables = {'requests', 'revision_history'} health = {'ok': True, 'missing_columns': [], 'missing_tables': [], 'message': 'Database schema looks good.'} @@ -1670,6 +1224,20 @@ def admin_settings(): 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') @@ -1727,6 +1295,7 @@ def admin_settings(): 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, ) diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..d0804eb --- /dev/null +++ b/helpers.py @@ -0,0 +1,502 @@ +""" +helpers.py +========== +Utility and configuration helpers for the Theme Song Booth Flask app. + +These functions are stateless (or use Flask's current_app / session context) +and are imported by app.py. Keeping them here reduces the size of the route file. +""" + +import os +import re +import shutil +import smtplib +import ssl +import time +import base64 +import hmac +import hashlib +import secrets +import json +from email.message import EmailMessage +from pathlib import Path + +from flask import session, current_app, flash +from werkzeug.utils import secure_filename + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from mutagen.mp3 import MP3 +from mutagen.easyid3 import EasyID3 + +import requests + + +# --------------------------------------------------------------------------- +# Genre / decade helpers +# --------------------------------------------------------------------------- + +_GENRES_PATH = Path('/mnt/Storage/Music Genres.txt') +_DECADES_PATH = Path('/mnt/Storage/Decades.txt') +_FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres.txt' +_FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt' + + +def _load_lines(path: Path) -> list[str]: + """Load a text file and return non-empty stripped lines.""" + if not path.exists(): + return [] + lines = path.read_text(encoding='utf-8').splitlines() + return [line.strip() for line in lines if line.strip()] + + +def _load_list(primary: Path, fallback: Path) -> list[str]: + """Load from the primary path, falling back to the bundled copy.""" + lines = _load_lines(primary) + if lines: + return lines + return _load_lines(fallback) + + +MUSIC_GENRES = _load_list(_GENRES_PATH, _FALLBACK_GENRES_PATH) +DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH) + + +def parse_style_genre(style_genre: str | None) -> dict: + """ + Split a stored combined style string into decade, basic, and additional. + The stored format is 'Decade, Basic, Additional' (additional may be empty). + """ + parts = [p.strip() for p in (style_genre or '').split(',') if p.strip()] + return { + 'decade': parts[0] if len(parts) > 0 else '', + 'basic_style': parts[1] if len(parts) > 1 else '', + 'additional_style': ', '.join(parts[2:]) if len(parts) > 2 else '', + } + + +def build_style_genre(decade: str, basic_style: str, additional_style: str) -> str: + """Build the combined style_genre string stored in the database.""" + parts = [p.strip() for p in [decade, basic_style, additional_style] if p.strip()] + return ', '.join(parts) + + +# --------------------------------------------------------------------------- +# Auth / validation helpers +# --------------------------------------------------------------------------- + +def is_admin(): + """Return True if the current browser session is logged in as admin.""" + return session.get('admin') is True + + +def require_admin(): + """Redirect to the admin login page if the user is not logged in.""" + from flask import redirect, url_for + if not is_admin(): + return redirect(url_for('admin_login')) + + +def admin_password_ok(pw): + """Check the submitted admin password against the configured one.""" + return pw and pw == current_app.config['ADMIN_PASSWORD'] + + +def is_valid_email(email): + """Return True if the given string looks like a valid email address.""" + if not email: + return False + pattern = r"^[^\s@]+@[^\s@]+\.[^\s@]+$" + return re.match(pattern, email) is not None + + +# --------------------------------------------------------------------------- +# File upload helpers +# --------------------------------------------------------------------------- + +def allowed_file(filename): + """Return True if the uploaded filename has an allowed extension (mp3).""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] + + +def upload_path(request_id): + """Return the per-request upload directory path, creating it if necessary.""" + p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id) + p.mkdir(parents=True, exist_ok=True) + return p + + +def save_upload(request_id, file_obj, version, song_title=None): + """ + Save an uploaded MP3 file for a request, preserving the original filename + with a version prefix. Applies the configured metadata tags. + """ + if not file_obj or file_obj.filename == '': + return None + if not allowed_file(file_obj.filename): + flash('Only MP3 files are allowed.', 'error') + return None + original = secure_filename(file_obj.filename) + filename = f"{version.upper()} - {original}" + p = upload_path(request_id) + dest = p / filename + file_obj.save(dest) + apply_mp3_tags(str(dest), song_title) + return str(dest) + + +def apply_mp3_tags(path, title=None): + """ + Write common ID3 tags on an uploaded MP3 using the runtime metadata defaults. + Failures are logged as a warning and do not block the upload. + """ + cfg = load_booth_settings() + try: + audio = MP3(path) + if audio.tags is None: + audio.add_tags() + if not isinstance(audio.tags, EasyID3): + audio.tags = EasyID3() + tags = audio.tags + if title: + tags['title'] = title + if cfg.get('artist'): + tags['artist'] = cfg['artist'] + if cfg.get('album'): + tags['album'] = cfg['album'] + if cfg.get('year'): + tags['date'] = str(cfg['year']) + audio.save() + if cfg.get('comment'): + from mutagen.id3 import COMM, TXXX + audio2 = MP3(path) + if audio2.tags is None: + audio2.add_tags() + audio2.tags["COMM"] = COMM(encoding=3, lang='eng', desc='Comment', text=cfg['comment']) + audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment']) + audio2.save() + except Exception as e: + try: + flash(f'Warning: could not tag MP3: {e}', 'error') + except RuntimeError: + import logging + logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e) + + +# --------------------------------------------------------------------------- +# Encryption / settings helpers +# --------------------------------------------------------------------------- + +def _get_fernet(): + """Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted.""" + secret = current_app.config['SECRET_KEY'].encode() + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b'theme-song-booth-v1', + iterations=480000, + ) + key = base64.urlsafe_b64encode(kdf.derive(secret)) + return Fernet(key) + + +def encrypt_value(value): + """Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext.""" + if not value: + return '' + return _get_fernet().encrypt(value.encode()).decode() + + +def decrypt_value(ciphertext): + """Decrypt a string previously encrypted by encrypt_value.""" + if not ciphertext: + return '' + try: + return _get_fernet().decrypt(ciphertext.encode()).decode() + except Exception: + return '' + + +def decrypt_value_legacy(ciphertext): + """Decrypt or return plaintext. Tolerates unencrypted legacy values.""" + if not ciphertext: + return '' + plaintext = decrypt_value(ciphertext) + if plaintext: + return plaintext + if not ciphertext.endswith('='): + return ciphertext + return '' + + +def settings_file_path(): + """Return the path to the persistent runtime settings JSON file.""" + return Path(current_app.config['DATABASE']).parent / current_app.config['SETTINGS_FILE'] + + +def load_booth_settings(): + """Load persistent runtime settings from JSON file inside the upload parent.""" + cfg_path = settings_file_path() + if cfg_path.exists(): + try: + cfg = json.loads(cfg_path.read_text()) + for key in ('artist', 'album', 'year', 'comment', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from'): + if cfg.get(key) is None: + cfg[key] = '' + return cfg + except (json.JSONDecodeError, OSError): + pass + return {} + + +def save_booth_settings(settings): + """Persist runtime settings to JSON file.""" + cfg_path = settings_file_path() + try: + cfg_path.write_text(json.dumps(settings, indent=2)) + except OSError as e: + flash(f'Warning: could not save settings: {e}', 'error') + + +# --------------------------------------------------------------------------- +# Config getters +# --------------------------------------------------------------------------- + +def get_email_config(): + """Return the effective SMTP configuration.""" + cfg = load_booth_settings() + return { + 'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']), + 'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']), + 'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']), + 'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'], + 'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']), + } + + +def get_refresh_seconds(): + """Return the dashboard auto-refresh interval in seconds (10, 20, or 30).""" + cfg = load_booth_settings() + try: + val = int(cfg.get('refresh_seconds', 10)) + except (ValueError, TypeError): + val = 10 + return val if val in (10, 20, 30) else 10 + + +def get_kiosk_cycle_seconds(): + """Return the kiosk slide cycle interval in seconds.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('kiosk_cycle_seconds', 10)) + except (ValueError, TypeError): + val = 10 + if val == 0: + return 0 + return max(5, val) + + +def get_kiosk_mode(): + """Return 'qr', 'prices', 'queue', or 'cycle' based on kiosk_cycle_seconds setting.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('kiosk_cycle_seconds', 10)) + except (ValueError, TypeError): + val = 10 + if val == -1: + return 'qr' + if val == 0: + return 'prices' + if val == 1: + return 'queue' + return 'cycle' + + +def get_max_revisions(): + """Return the effective max revisions as an integer.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2))) + except (ValueError, TypeError): + val = current_app.config.get('MAX_REVISIONS', 2) + return max(0, val) + + +def get_callback_expiry_hours(): + """Return the Hermes signed callback token lifetime in hours (default 168 = 7 days).""" + cfg = load_booth_settings() + try: + val = int(cfg.get('callback_expiry_hours', 168)) + except (ValueError, TypeError): + val = 168 + return max(1, val) + + +def get_hermes_api_key(): + """Return the effective Hermes API key.""" + env_key = current_app.config.get('HERMES_API_KEY', '') + if env_key: + return env_key + cfg = load_booth_settings() + return decrypt_value_legacy(cfg.get('hermes_api_key', '')) + + +def set_hermes_api_key(key): + """Persist a new Hermes API key (encrypted) to runtime settings.""" + cfg = load_booth_settings() + cfg['hermes_api_key'] = encrypt_value(key) + save_booth_settings(cfg) + + +def generate_hermes_api_key(): + """Generate a new random API key for Hermes callback authentication.""" + return secrets.token_urlsafe(32) + + +def mask_api_key(key): + """Return a masked version of the API key showing only the last 6 characters.""" + if not key: + return 'Not set' + if len(key) <= 6: + return '*' * len(key) + return '*' * (len(key) - 6) + key[-6:] + + +def get_ntfy_config(): + """Return the effective ntfy server URL, topic, and access token from runtime settings.""" + cfg = load_booth_settings() + return { + 'server': cfg.get('ntfy_server', ''), + 'topic': cfg.get('ntfy_topic', ''), + 'token': decrypt_value(cfg.get('ntfy_token', '')) or '', + } + + +def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'): + """Send a push notification to the configured ntfy topic, if configured.""" + ntfy = get_ntfy_config() + server = ntfy.get('server', '').rstrip('/') + topic = ntfy.get('topic', '').strip() + if not server or not topic: + return False + + url = f"{server}/{topic}" + headers = { + 'Title': title, + 'Priority': priority, + 'Tags': tags, + } + token = ntfy.get('token', '') + if token: + headers['Authorization'] = f'Bearer {token}' + try: + resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10) + return resp.status_code in (200, 202) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Signed callback helpers +# --------------------------------------------------------------------------- + +def sign_prompt_callback(rid, expires_at=None): + """ + Create a signed callback token for a specific request ID. + The signature is HMAC-SHA256 over "rid:expires_at" using APP_SECRET_KEY. + """ + secret = current_app.config['SECRET_KEY'].encode() + if expires_at is None: + expires_at = int(time.time()) + get_callback_expiry_hours() * 3600 + payload = f"{rid}:{expires_at}" + sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:16] + return f"{rid}:{expires_at}:{sig}" + + +def verify_prompt_callback(token): + """Verify a signed callback token. Returns (rid, ok) tuple.""" + if not token: + return None, False + parts = token.split(':') + if len(parts) != 3: + return None, False + try: + rid = int(parts[0]) + expires_at = int(parts[1]) + except ValueError: + return None, False + if int(time.time()) > expires_at: + return None, False + expected = sign_prompt_callback(rid, expires_at) + if not hmac.compare_digest(expected, token): + return None, False + return rid, True + + +def build_prompt_callback_url(rid): + """Build the full callback URL an operator pastes into Hermes for a request.""" + from flask import url_for + token = sign_prompt_callback(rid) + return f"{current_app.config['PUBLIC_BASE_URL']}/api/prompt/{rid}?token={token}" + + +# --------------------------------------------------------------------------- +# Email helpers +# --------------------------------------------------------------------------- + +def send_email(to, subject, body, attachments=None, inline_images=None): + """Send an email using the configured or runtime SMTP settings.""" + cfg = get_email_config() + if not cfg['SMTP_PASS']: + raise RuntimeError('SMTP password is not configured') + + msg = EmailMessage() + msg['From'] = cfg['SMTP_FROM'] + msg['To'] = to + msg['Subject'] = subject + msg.set_content(body) + + html_body = body.replace('\n', '
\n') + if inline_images: + for _, cid in inline_images: + html_body += f'
Dionysis Media' + html_body += f'


Dionysis Media: stories, sound, and a little divine chaos — https://dionysismedia.ca/

' + msg.add_alternative(html_body, subtype='html') + + if inline_images: + for path, cid in inline_images: + with open(path, 'rb') as f: + data = f.read() + ext = Path(path).suffix.lower().lstrip('.') + subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png' + msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>') + + if attachments: + for path, name in attachments: + with open(path, 'rb') as f: + data = f.read() + msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name) + + with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server: + server.login(cfg['SMTP_USER'], cfg['SMTP_PASS']) + server.send_message(msg) + + +def build_signature_images(): + """Return inline image tuple list for static/DM-Logo_email.png.""" + logo_path = Path(current_app.root_path) / 'static' / 'DM-Logo_email.png' + if not logo_path.exists(): + return [] + return [(str(logo_path), 'dm-logo')] + + +# --------------------------------------------------------------------------- +# Booth state +# --------------------------------------------------------------------------- + +def get_booth_open(): + """Return True if the booth is currently marked as open in runtime settings.""" + cfg = load_booth_settings() + return cfg.get('booth_open', True) diff --git a/models.py b/models.py index f56f30d..0baa0b6 100644 --- a/models.py +++ b/models.py @@ -52,7 +52,8 @@ CREATE TABLE IF NOT EXISTS requests ( revision_count INTEGER DEFAULT 0, revision_note TEXT, operator_notes TEXT, - stems_link TEXT + stems_link TEXT, + stems_interest INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status); @@ -107,7 +108,7 @@ def init_db(): 'customer_approved', 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_count', 'revision_note', 'operator_notes', - 'stems_link' + 'stems_link', 'stems_interest' ], 'revision_history': [ 'id', 'created_at', 'request_id', 'revision_count', 'note', @@ -135,7 +136,7 @@ def now_utc(): return datetime.now(timezone.utc).isoformat() -def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests, vocal_gender=None, pronouns=None): +def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests, vocal_gender=None, pronouns=None, stems_interest=0): """ Insert a new customer request. Returns the auto-generated request id. @@ -143,9 +144,9 @@ def create_request(name, email, hobbies, notable_facts, style_genre, extra_reque db = get_db() cur = db.execute( """INSERT INTO requests - (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, player_token) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", - (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, new_token()) + (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, player_token, stems_interest) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, new_token(), 1 if stems_interest else 0) ) db.commit() return cur.lastrowid diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 5dbaf78..c769d4a 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -131,7 +131,7 @@
All Pending - Needs Upload + Needs Upload Awaiting Payment Delivered diff --git a/templates/admin/request.html b/templates/admin/request.html index 5d50cf7..75a4628 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -245,7 +245,12 @@ - + + +
@@ -433,6 +438,17 @@ {% if req.square_payment_ref %}

Square Payment Reference: {{ req.square_payment_ref }}

{% endif %} +
+ + + +
+ +
+
+ +
+
@@ -460,8 +476,8 @@

No files available. Upload songs first.

{% endfor %} - - Square Payment Reference (required to deliver) +
@@ -492,9 +508,10 @@ notable_facts: {{ req.notable_facts | tojson }}, style_genre: styleGenre, vocal_gender: {{ req.vocal_gender | tojson }}, - extra_requests: {{ req.extra_requests | tojson }} + extra_requests: {{ req.extra_requests | tojson }}, + stems_interest: {{ (req.stems_interest or 0) | tojson }} }; - const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`; + const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`; navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL.")); } @@ -527,9 +544,10 @@ notable_facts: {{ req.notable_facts | tojson }}, style_genre: styleGenre, vocal_gender: {{ req.vocal_gender | tojson }}, - extra_requests: {{ req.extra_requests | tojson }} + extra_requests: {{ req.extra_requests | tojson }}, + stems_interest: {{ (req.stems_interest or 0) | tojson }} }; - const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`; + const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`; navigator.clipboard.writeText(text).then(() => alert("Revision prompt copied! Paste into Hermes. Hermes will POST the revised prompt back to the Callback URL.")); } diff --git a/templates/admin/settings.html b/templates/admin/settings.html index f94a0f2..7f94a79 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -291,6 +291,17 @@ {% if not hermes_key_set %}

⚠️ No Hermes API key is configured. Set the HERMES_API_KEY environment variable in Portainer before using the callback workflow.

{% endif %} + +
+ +

Callback Link Expiry

+

How long the signed Hermes callback URL stays valid (in hours). Default is 168 hours (7 days).

+ + + + + +
diff --git a/templates/request.html b/templates/request.html index 3030627..2e6e78a 100644 --- a/templates/request.html +++ b/templates/request.html @@ -149,10 +149,10 @@ - + - + - + + +