v0.6.0: form limits, callback expiry, dashboard filter, payment ref, STEMS interest, helper refactor

This commit is contained in:
Troll (Hermes Agent) 2026-08-09 05:18:40 +00:00
parent 0bfe8c315c
commit 0a01381d44
9 changed files with 603 additions and 497 deletions

View file

@ -1,6 +1,6 @@
# Theme Song Booth # 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. 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.

View file

@ -1 +1 @@
0.5.9 0.6.0

525
app.py
View file

@ -30,95 +30,36 @@ Admin routes:
# Standard library imports # Standard library imports
import os import os
import re
import shutil import shutil
import smtplib
import ssl
import time import time
from email.message import EmailMessage
from pathlib import Path 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 # 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 import Limiter
from flask_limiter.util import get_remote_address from flask_limiter.util import get_remote_address
from werkzeug.utils import secure_filename
# Project imports # Project imports
from config import Config 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 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 from models import SCHEMA, get_requests_by_email, log_revision, list_revision_history
# Audio metadata import # Helpers (extracted from app.py to keep the route file manageable)
from mutagen.mp3 import MP3 from helpers import (
from mutagen.easyid3 import EasyID3 MUSIC_GENRES, DECADES, parse_style_genre, build_style_genre,
is_admin, require_admin, admin_password_ok, is_valid_email,
# HTTP client for ntfy push notifications allowed_file, upload_path, save_upload,
import requests 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,
# Genre / decade helpers 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,
# Load external lists once at import time. These are shared by the customer sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url,
# request form and the admin request editor so the dropdowns stay in sync. send_email, build_signature_images,
# In production the canonical files live under /mnt/Storage. We also bundle get_booth_open,
# 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)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -147,403 +88,6 @@ STATUS_LABELS = {
'cancelled': 'Cancelled', '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', '<br>\n')
if inline_images:
for _, cid in inline_images:
html_body += f'<br><img src="cid:{cid}" alt="Dionysis Media" style="max-width:200px;margin-top:1rem;"/>'
html_body += f'<br><br><hr style="border:none;border-top:1px solid #ddd;"/><p style="font-size:0.9rem;color:#555;">Dionysis Media: stories, sound, and a little divine chaos — <a href="https://dionysismedia.ca/">https://dionysismedia.ca/</a></p>'
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 # Public customer routes
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -585,11 +129,12 @@ def request_form():
'name': request.form.get('name', '').strip(), 'name': request.form.get('name', '').strip(),
'email': request.form.get('email', '').strip().lower(), 'email': request.form.get('email', '').strip().lower(),
'pronouns': pronouns, 'pronouns': pronouns,
'hobbies': request.form.get('hobbies', '').strip(), 'hobbies': request.form.get('hobbies', '').strip()[:2000],
'notable_facts': request.form.get('notable_facts', '').strip(), 'notable_facts': request.form.get('notable_facts', '').strip()[:2000],
'style_genre': build_style_genre(decade, basic_style, additional_style), '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(), 'vocal_gender': request.form.get('vocal_gender', '').strip(),
'stems_interest': bool(request.form.get('stems_interest')),
} }
if not is_valid_email(form_data['email']): if not is_valid_email(form_data['email']):
flash('Please enter a valid email address.', 'error') flash('Please enter a valid email address.', 'error')
@ -626,6 +171,7 @@ def request_form():
f"Hobbies: {req['hobbies'] or '-'}", f"Hobbies: {req['hobbies'] or '-'}",
f"Notable facts: {req['notable_facts'] or '-'}", f"Notable facts: {req['notable_facts'] or '-'}",
f"Extra requests: {req['extra_requests'] 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.", "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(), notable_facts=request.form.get('notable_facts', '').strip(),
style_genre=style_genre, style_genre=style_genre,
vocal_gender=request.form.get('vocal_gender', '').strip(), 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') flash('Customer info updated.', 'success')
return redirect(url_for('admin_request', rid=rid)) return redirect(url_for('admin_request', rid=rid))
@ -1270,6 +817,13 @@ def admin_request(rid):
except Exception as e: except Exception as e:
flash(f'Failed to send preview email: {e}', 'error') 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': elif action == 'mark_paid_deliver':
# Finalize: record Square payment ref, attach approved MP3s, email customer. # Finalize: record Square payment ref, attach approved MP3s, email customer.
if req.get('customer_approved', 'none') == 'none': 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', 'style_genre', 'extra_requests', 'vocal_gender', 'status', 'suno_title', 'suno_style',
'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved', 'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved',
'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', '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'} expected_tables = {'requests', 'revision_history'}
health = {'ok': True, 'missing_columns': [], 'missing_tables': [], 'message': 'Database schema looks good.'} 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') flash(f'Database restore failed: {e}', 'error')
return redirect(url_for('admin_settings')) 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': elif action == 'regenerate_hermes_key':
# Legacy action: no longer exposed in UI. Key is managed via HERMES_API_KEY env var. # 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') 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_set=hermes_key_set,
hermes_key_just_generated=hermes_key_just_generated, hermes_key_just_generated=hermes_key_just_generated,
version=current_app.config['VERSION'], version=current_app.config['VERSION'],
current_callback_expiry_hours=get_callback_expiry_hours(),
ntfy=runtime_settings, ntfy=runtime_settings,
) )

502
helpers.py Normal file
View file

@ -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', '<br>\n')
if inline_images:
for _, cid in inline_images:
html_body += f'<br><img src="cid:{cid}" alt="Dionysis Media" style="max-width:200px;margin-top:1rem;"/>'
html_body += f'<br><br><hr style="border:none;border-top:1px solid #ddd;"/><p style="font-size:0.9rem;color:#555;">Dionysis Media: stories, sound, and a little divine chaos — <a href="https://dionysismedia.ca/">https://dionysismedia.ca/</a></p>'
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)

View file

@ -52,7 +52,8 @@ CREATE TABLE IF NOT EXISTS requests (
revision_count INTEGER DEFAULT 0, revision_count INTEGER DEFAULT 0,
revision_note TEXT, revision_note TEXT,
operator_notes 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); 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', 'customer_approved', 'approval_notified_at', 'preview_sent_at',
'delivery_sent_at', 'square_payment_ref', 'admin_alert_email', 'delivery_sent_at', 'square_payment_ref', 'admin_alert_email',
'player_token', 'revision_count', 'revision_note', 'operator_notes', 'player_token', 'revision_count', 'revision_note', 'operator_notes',
'stems_link' 'stems_link', 'stems_interest'
], ],
'revision_history': [ 'revision_history': [
'id', 'created_at', 'request_id', 'revision_count', 'note', 'id', 'created_at', 'request_id', 'revision_count', 'note',
@ -135,7 +136,7 @@ def now_utc():
return datetime.now(timezone.utc).isoformat() 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. Insert a new customer request.
Returns the auto-generated request id. 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() db = get_db()
cur = db.execute( cur = db.execute(
"""INSERT INTO requests """INSERT INTO requests
(name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, player_token) (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, player_token, stems_interest)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", 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, new_token(), 1 if stems_interest else 0)
) )
db.commit() db.commit()
return cur.lastrowid return cur.lastrowid

View file

@ -131,7 +131,7 @@
<div class="filters"> <div class="filters">
<a href="{{ url_for('admin_dashboard') }}" class="{% if current_status in (None, '') %}active{% endif %}">All</a> <a href="{{ url_for('admin_dashboard') }}" class="{% if current_status in (None, '') %}active{% endif %}">All</a>
<a href="{{ url_for('admin_dashboard', status='pending') }}" class="{% if current_status == 'pending' %}active{% endif %}">Pending</a> <a href="{{ url_for('admin_dashboard', status='pending') }}" class="{% if current_status == 'pending' %}active{% endif %}">Pending</a>
<a href="{{ url_for('admin_dashboard', status='songs_uploaded') }}" class="{% if current_status == 'songs_uploaded' %}active{% endif %}">Needs Upload</a> <a href="{{ url_for('admin_dashboard', status='prompt_ready') }}" class="{% if current_status == 'prompt_ready' %}active{% endif %}">Needs Upload</a>
<a href="{{ url_for('admin_dashboard', status='awaiting_payment') }}" class="{% if current_status == 'awaiting_payment' %}active{% endif %}">Awaiting Payment</a> <a href="{{ url_for('admin_dashboard', status='awaiting_payment') }}" class="{% if current_status == 'awaiting_payment' %}active{% endif %}">Awaiting Payment</a>
<a href="{{ url_for('admin_dashboard', status='delivered') }}" class="{% if current_status == 'delivered' %}active{% endif %}">Delivered</a> <a href="{{ url_for('admin_dashboard', status='delivered') }}" class="{% if current_status == 'delivered' %}active{% endif %}">Delivered</a>
<span class="refresh-hint"> <span class="refresh-hint">

View file

@ -245,7 +245,12 @@
</select> </select>
<label for="customer_extra_requests">Anything else</label> <label for="customer_extra_requests">Anything else</label>
<textarea id="customer_extra_requests" name="extra_requests" rows="3">{{ req.extra_requests or '' }}</textarea> <textarea id="customer_extra_requests" name="extra_requests" rows="3" maxlength="2000">{{ req.extra_requests or '' }}</textarea>
<label for="customer_stems_interest" style="display:flex;align-items:center;gap:.5rem;margin-top:1rem;cursor:pointer;">
<input type="checkbox" id="customer_stems_interest" name="stems_interest" value="1" {% if req.stems_interest %}checked{% endif %} style="width:auto;margin:0">
Customer is interested in STEMS / multitrack files
</label>
<div class="actions"> <div class="actions">
<button type="submit" class="secondary">Save Customer Info</button> <button type="submit" class="secondary">Save Customer Info</button>
@ -433,6 +438,17 @@
{% if req.square_payment_ref %} {% if req.square_payment_ref %}
<p><strong>Square Payment Reference:</strong> {{ req.square_payment_ref }}</p> <p><strong>Square Payment Reference:</strong> {{ req.square_payment_ref }}</p>
{% endif %} {% endif %}
<form method="POST">
<input type="hidden" name="action" value="update_payment_ref">
<label for="square_payment_ref_edit">Square Payment Reference</label>
<input type="text" id="square_payment_ref_edit" name="square_payment_ref" value="{{ req.square_payment_ref or '' }}" placeholder="e.g. sq0idp-... or receipt number">
<div class="actions">
<button type="submit" class="secondary">Update Payment Reference</button>
</div>
</form>
<hr style="border-color:#374151;margin:1rem 0">
<form method="POST"> <form method="POST">
<input type="hidden" name="action" value="mark_paid_deliver"> <input type="hidden" name="action" value="mark_paid_deliver">
@ -460,8 +476,8 @@
<p>No files available. Upload songs first.</p> <p>No files available. Upload songs first.</p>
{% endfor %} {% endfor %}
<label for="square_payment_ref">Square Payment Reference</label> <label for="square_payment_ref">Square Payment Reference (required to deliver)</label>
<input type="text" id="square_payment_ref" name="square_payment_ref" placeholder="e.g. sq0idp-... or receipt number" <input type="text" id="square_payment_ref" name="square_payment_ref" value="{{ req.square_payment_ref or '' }}" placeholder="e.g. sq0idp-... or receipt number"
{% if req.customer_approved == 'none' %}disabled{% endif %}> {% if req.customer_approved == 'none' %}disabled{% endif %}>
<div class="actions"> <div class="actions">
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid &amp; Deliver</button> <button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid &amp; Deliver</button>
@ -492,9 +508,10 @@
notable_facts: {{ req.notable_facts | tojson }}, notable_facts: {{ req.notable_facts | tojson }},
style_genre: styleGenre, style_genre: styleGenre,
vocal_gender: {{ req.vocal_gender | tojson }}, 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.")); 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 }}, notable_facts: {{ req.notable_facts | tojson }},
style_genre: styleGenre, style_genre: styleGenre,
vocal_gender: {{ req.vocal_gender | tojson }}, 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.")); navigator.clipboard.writeText(text).then(() => alert("Revision prompt copied! Paste into Hermes. Hermes will POST the revised prompt back to the Callback URL."));
} }
</script> </script>

View file

@ -291,6 +291,17 @@
{% if not hermes_key_set %} {% if not hermes_key_set %}
<p class="status-bad">⚠️ No Hermes API key is configured. Set the HERMES_API_KEY environment variable in Portainer before using the callback workflow.</p> <p class="status-bad">⚠️ No Hermes API key is configured. Set the HERMES_API_KEY environment variable in Portainer before using the callback workflow.</p>
{% endif %} {% endif %}
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0;">
<h3>Callback Link Expiry</h3>
<p class="copy-hint">How long the signed Hermes callback URL stays valid (in hours). Default is 168 hours (7 days).</p>
<form method="POST">
<input type="hidden" name="action" value="save_callback_expiry">
<label for="callback_expiry_hours">Callback expiry (hours)</label>
<input type="number" id="callback_expiry_hours" name="callback_expiry_hours" value="{{ current_callback_expiry_hours }}" min="1">
<button type="submit">Save Callback Expiry</button>
</form>
</div> </div>
<!-- ntfy push notifications --> <!-- ntfy push notifications -->

View file

@ -149,10 +149,10 @@
</select> </select>
<label for="hobbies">Hobbies &amp; interests</label> <label for="hobbies">Hobbies &amp; interests</label>
<textarea id="hobbies" name="hobbies" placeholder="e.g. rock climbing, retro gaming, sourdough baking">{{ form.hobbies if form else '' }}</textarea> <textarea id="hobbies" name="hobbies" maxlength="2000" placeholder="e.g. rock climbing, retro gaming, sourdough baking">{{ form.hobbies if form else '' }}</textarea>
<label for="notable_facts">Notable things about you</label> <label for="notable_facts">Notable things about you</label>
<textarea id="notable_facts" name="notable_facts" placeholder="Anything fun, weird, or heroic we should mention">{{ form.notable_facts if form else '' }}</textarea> <textarea id="notable_facts" name="notable_facts" maxlength="2000" placeholder="Anything fun, weird, or heroic we should mention">{{ form.notable_facts if form else '' }}</textarea>
<label for="decade">Decade / era <span style="color:#f87171">*</span></label> <label for="decade">Decade / era <span style="color:#f87171">*</span></label>
<select id="decade" name="decade" required> <select id="decade" name="decade" required>
@ -208,7 +208,12 @@
</select> </select>
<label for="extra_requests">Anything else you want in the song? (Trollgorithm is very literal, so be careful!)</label> <label for="extra_requests">Anything else you want in the song? (Trollgorithm is very literal, so be careful!)</label>
<textarea id="extra_requests" name="extra_requests" placeholder="Specific Lyrics, clean/explicit...">{{ form.extra_requests if form else '' }}</textarea> <textarea id="extra_requests" name="extra_requests" maxlength="2000" placeholder="Specific Lyrics, clean/explicit...">{{ form.extra_requests if form else '' }}</textarea>
<label for="stems_interest" style="display:flex;align-items:center;gap:.5rem;margin-top:1rem;cursor:pointer;">
<input type="checkbox" id="stems_interest" name="stems_interest" value="1" {% if form and form.stems_interest %}checked{% endif %} style="width:auto;margin:0">
I'm interested in STEMS / multitrack files (if you know, you know)
</label>
<button type="submit">Submit Request</button> <button type="submit">Submit Request</button>
</form> </form>