Initial prototype for theme song booth
This commit is contained in:
commit
c16d48ee32
16 changed files with 898 additions and 0 deletions
14
.env.example
Normal file
14
.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
APP_SECRET_KEY=change-me-in-production
|
||||
ADMIN_PASSWORD_HASH=
|
||||
SMTP_HOST=mailroot8.namespro.ca
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=ai@hallsworth.ca
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=ai@hallsworth.ca
|
||||
ADMIN_ALERT_EMAIL=ai@hallsworth.ca
|
||||
PUBLIC_BASE_URL=http://127.0.0.1:5000
|
||||
BOOTH_NAME=Trollgorithm Theme Songs
|
||||
DATABASE=/app/data/booth.db
|
||||
UPLOAD_FOLDER=/app/uploads
|
||||
PRICE_PER_VERSION=10.00
|
||||
CURRENCY=CAD
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
data/
|
||||
uploads/
|
||||
*.db
|
||||
*.mp3
|
||||
*.wav
|
||||
.DS_Store
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app
|
||||
USER boothuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:8000", "--access-logfile", "-", "app:app"]
|
||||
50
README.md
Normal file
50
README.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Theme Song Booth
|
||||
|
||||
Prototype web app for a convention booth where attendees request custom AI-generated theme songs.
|
||||
|
||||
## Flow
|
||||
|
||||
1. Customer fills out the public request form at `/request`.
|
||||
2. Operator generates a Suno Custom Mode prompt via Hermes and saves it in the admin detail page.
|
||||
3. Operator generates two song versions in Suno and uploads the MP3s in admin.
|
||||
4. Operator clicks **Send Preview Link**. Customer receives an email with a private player page.
|
||||
5. Customer listens to Version A and Version B, then approves one/both or requests changes.
|
||||
6. Operator sees the approval alert, collects payment via Square reader, then clicks **Mark Paid & Deliver**.
|
||||
7. Customer receives the approved MP3(s) as email attachments.
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
cd /home/jess/workspace/theme-song-booth
|
||||
python3 -m venv .venv
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# Edit .env and set APP_SECRET_KEY, ADMIN_PASSWORD_HASH, SMTP_PASS, PUBLIC_BASE_URL
|
||||
.venv/bin/python init_db.py
|
||||
.venv/bin/python -m flask --app app run --host=0.0.0.0
|
||||
```
|
||||
|
||||
Generate an admin password hash with:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('yourpassword'))"
|
||||
```
|
||||
|
||||
## Deployment on Unraid
|
||||
|
||||
1. Copy the project directory to your Unraid server or build it via git.
|
||||
2. Create `/mnt/user/appdata/theme-song-booth/data` and `/mnt/user/appdata/theme-song-booth/uploads`.
|
||||
3. Copy `.env.example` to `.env`, fill in real values, and place it next to `docker-compose.yml`.
|
||||
4. Run `docker compose up -d --build`.
|
||||
5. Initialize the database once: `docker compose exec booth python init_db.py`.
|
||||
6. Point your chosen domain at the Unraid server's public IP and route it through your reverse proxy to `http://127.0.0.1:8000`.
|
||||
7. Print the booth QR code pointing to `https://your-domain.example.com/request`.
|
||||
|
||||
## Files
|
||||
|
||||
- `app.py` — Flask application with public/admin routes and email logic.
|
||||
- `models.py` — SQLite schema and helper functions.
|
||||
- `config.py` — Configuration loaded from environment.
|
||||
- `templates/` — Jinja2 HTML templates.
|
||||
- `init_db.py` — Standalone script to create the SQLite database.
|
||||
- `Dockerfile` / `docker-compose.yml` — Container packaging for Unraid.
|
||||
285
app.py
Normal file
285
app.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
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
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
STATUS_LABELS = {
|
||||
'pending': 'Pending',
|
||||
'prompt_ready': 'Prompt Ready',
|
||||
'songs_uploaded': 'Songs Uploaded — Awaiting Approval',
|
||||
'awaiting_payment': 'Awaiting Payment',
|
||||
'paid': 'Paid',
|
||||
'delivered': 'Delivered',
|
||||
}
|
||||
|
||||
# ---------------- helpers ----------------
|
||||
|
||||
def is_admin():
|
||||
return session.get('admin') is True
|
||||
|
||||
def require_admin():
|
||||
if not is_admin():
|
||||
return redirect(url_for('admin_login'))
|
||||
|
||||
def allowed_file(filename):
|
||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS']
|
||||
|
||||
def upload_path(request_id):
|
||||
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):
|
||||
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
|
||||
p = upload_path(request_id)
|
||||
filename = f'song_{version}.mp3'
|
||||
file_obj.save(p / filename)
|
||||
return str(p / filename)
|
||||
|
||||
def send_email(to, subject, body, attachments=None):
|
||||
cfg = current_app.config
|
||||
if not cfg['SMTP_PASS']:
|
||||
raise RuntimeError('SMTP_PASS is not configured')
|
||||
|
||||
msg = EmailMessage()
|
||||
msg['From'] = cfg['SMTP_FROM']
|
||||
msg['To'] = to
|
||||
msg['Subject'] = subject
|
||||
msg.set_content(body)
|
||||
|
||||
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)
|
||||
|
||||
# ---------------- public ----------------
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return redirect(url_for('request_form'))
|
||||
|
||||
@app.route('/request', methods=['GET', 'POST'])
|
||||
def request_form():
|
||||
if request.method == 'POST':
|
||||
rid = create_request(
|
||||
name=request.form.get('name', '').strip(),
|
||||
email=request.form.get('email', '').strip(),
|
||||
hobbies=request.form.get('hobbies', '').strip(),
|
||||
notable_facts=request.form.get('notable_facts', '').strip(),
|
||||
style_genre=request.form.get('style_genre', '').strip(),
|
||||
extra_requests=request.form.get('extra_requests', '').strip(),
|
||||
)
|
||||
flash('Your request has been submitted! Check your email soon.', 'success')
|
||||
return redirect(url_for('thanks', rid=rid))
|
||||
return render_template('request.html')
|
||||
|
||||
@app.route('/thanks/<int:rid>')
|
||||
def thanks(rid):
|
||||
req = get_request_by_id(rid)
|
||||
if not req:
|
||||
abort(404)
|
||||
return render_template('thanks.html', req=req)
|
||||
|
||||
@app.route('/play/<token>')
|
||||
def play(token):
|
||||
req = get_request_by_token(token)
|
||||
if not req:
|
||||
abort(404)
|
||||
return render_template('player.html', req=req)
|
||||
|
||||
@app.route('/play/<token>/approve', methods=['POST'])
|
||||
def approve(token):
|
||||
req = get_request_by_token(token)
|
||||
if not req:
|
||||
abort(404)
|
||||
choice = request.form.get('choice')
|
||||
if choice not in ('a', 'b', 'both'):
|
||||
flash('Invalid selection.', 'error')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc())
|
||||
|
||||
# alert operator
|
||||
alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
|
||||
if alert_to:
|
||||
admin_link = f"{current_app.config['PUBLIC_BASE_URL']}/admin/request/{req['id']}"
|
||||
version_label = {'a': 'A', 'b': 'B', 'both': 'Both'}[choice]
|
||||
body = f"{req['name']} ({req['email']}) approved: Version {version_label}.\n\nRequest #{req['id']}\nPayment is now due.\n\nOpen admin: {admin_link}"
|
||||
try:
|
||||
send_email(alert_to, f"{req['name']} approved their theme song", body)
|
||||
except Exception as e:
|
||||
flash(f'Approval saved, but operator alert failed: {e}', 'warning')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
flash('Thanks! Please return to the booth to finalize payment.', 'success')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
@app.route('/play/<token>/revise', methods=['POST'])
|
||||
def revise(token):
|
||||
req = get_request_by_token(token)
|
||||
if not req:
|
||||
abort(404)
|
||||
note = request.form.get('revision_note', '').strip()
|
||||
update_request(req['id'], revision_note=note, status='songs_uploaded')
|
||||
|
||||
alert_to = current_app.config['ADMIN_ALERT_EMAIL'] or current_app.config['SMTP_FROM']
|
||||
if alert_to and note:
|
||||
admin_link = f"{current_app.config['PUBLIC_BASE_URL']}/admin/request/{req['id']}"
|
||||
body = f"{req['name']} ({req['email']}) requested changes for request #{req['id']}.\n\nNote:\n{note}\n\nOpen admin: {admin_link}"
|
||||
try:
|
||||
send_email(alert_to, f"{req['name']} requested changes", body)
|
||||
except Exception as e:
|
||||
flash(f'Revision saved, but operator alert failed: {e}', 'warning')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
flash('Your feedback has been sent. We will regenerate and update you.', 'success')
|
||||
return redirect(url_for('play', token=token))
|
||||
|
||||
@app.route('/audio/<token>/<version>.mp3')
|
||||
def audio(token, version):
|
||||
req = get_request_by_token(token)
|
||||
if not req:
|
||||
abort(404)
|
||||
if version not in ('a', 'b'):
|
||||
abort(404)
|
||||
field = f'song_{version}_path'
|
||||
path = req.get(field)
|
||||
if not path or not Path(path).exists():
|
||||
abort(404)
|
||||
return send_from_directory(Path(path).parent, Path(path).name)
|
||||
|
||||
# ---------------- admin ----------------
|
||||
|
||||
@app.route('/admin/login', methods=['GET', 'POST'])
|
||||
def admin_login():
|
||||
if is_admin():
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
if request.method == 'POST':
|
||||
pw_hash = current_app.config['ADMIN_PASSWORD_HASH']
|
||||
if not pw_hash:
|
||||
flash('Admin password is not configured.', 'error')
|
||||
elif check_password_hash(pw_hash, request.form.get('password', '')):
|
||||
session['admin'] = True
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
else:
|
||||
flash('Invalid password.', 'error')
|
||||
return render_template('admin/login.html')
|
||||
|
||||
@app.route('/admin/logout')
|
||||
def admin_logout():
|
||||
session.pop('admin', None)
|
||||
return redirect(url_for('admin_login'))
|
||||
|
||||
@app.route('/admin')
|
||||
def admin_dashboard():
|
||||
redir = require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
status_filter = request.args.get('status')
|
||||
requests = list_requests(status_filter)
|
||||
return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter)
|
||||
|
||||
@app.route('/admin/request/<int:rid>', methods=['GET', 'POST'])
|
||||
def admin_request(rid):
|
||||
redir = require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
req = get_request_by_id(rid)
|
||||
if not req:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
action = request.form.get('action')
|
||||
|
||||
if action == 'save_prompt':
|
||||
update_request(rid,
|
||||
suno_style=request.form.get('suno_style', '').strip(),
|
||||
suno_lyrics=request.form.get('suno_lyrics', '').strip(),
|
||||
status='prompt_ready'
|
||||
)
|
||||
flash('Prompt saved.', 'success')
|
||||
|
||||
elif action == 'upload_songs':
|
||||
a_path = save_upload(rid, request.files.get('song_a'), 'a')
|
||||
b_path = save_upload(rid, request.files.get('song_b'), 'b')
|
||||
fields = {}
|
||||
if a_path:
|
||||
fields['song_a_path'] = a_path
|
||||
if b_path:
|
||||
fields['song_b_path'] = b_path
|
||||
if fields:
|
||||
fields['status'] = 'songs_uploaded'
|
||||
update_request(rid, **fields)
|
||||
flash('Songs uploaded.', 'success')
|
||||
|
||||
elif action == 'notify_customer':
|
||||
if not (req['song_a_path'] and req['song_b_path']):
|
||||
flash('Both songs must be uploaded first.', 'error')
|
||||
else:
|
||||
player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}"
|
||||
body = f"Hi {req['name']},\n\nYour custom theme song has been created. Listen to both versions and let us know which one you want:\n\n{player_link}\n\n- Version A\n- Version B\n- Or both versions\n\nOnce you make your choice, we'll send you to the booth to finalize payment and deliver your files.\n\nThanks for stopping by!\n\n— {current_app.config['BOOTH_NAME']}"
|
||||
try:
|
||||
send_email(req['email'], 'Your custom theme song is ready — listen and pick your version', body)
|
||||
update_request(rid, preview_sent_at=now_utc(), status='songs_uploaded')
|
||||
flash('Preview email sent.', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Failed to send preview email: {e}', 'error')
|
||||
|
||||
elif action == 'mark_paid_deliver':
|
||||
if req['customer_approved'] == 'none':
|
||||
flash('Customer has not approved a version yet.', 'error')
|
||||
else:
|
||||
payment_ref = request.form.get('square_payment_ref', '').strip()
|
||||
if not payment_ref:
|
||||
flash('Square payment reference is required.', 'error')
|
||||
return redirect(url_for('admin_request', rid=rid))
|
||||
|
||||
attachments = []
|
||||
if req['customer_approved'] in ('a', 'both') and req['song_a_path']:
|
||||
attachments.append((req['song_a_path'], 'song_a.mp3'))
|
||||
if req['customer_approved'] in ('b', 'both') and req['song_b_path']:
|
||||
attachments.append((req['song_b_path'], 'song_b.mp3'))
|
||||
|
||||
player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}"
|
||||
body = f"Hi {req['name']},\n\nThanks for your payment! Your approved song is attached to this email.\n\nIf you selected both versions, you'll find two MP3 files.\n\nYou can also keep streaming them here: {player_link}\n\nEnjoy!\n\n— {current_app.config['BOOTH_NAME']}"
|
||||
try:
|
||||
send_email(req['email'], 'Your theme song files are here!', body, attachments=attachments)
|
||||
update_request(rid, square_payment_ref=payment_ref, delivery_sent_at=now_utc(), status='delivered')
|
||||
flash('Delivery email sent with MP3 attachments.', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Failed to send delivery email: {e}', 'error')
|
||||
|
||||
return redirect(url_for('admin_request', rid=rid))
|
||||
|
||||
return render_template('admin/request.html', req=req, statuses=STATUS_LABELS)
|
||||
|
||||
# ---------------- init ----------------
|
||||
|
||||
@app.cli.command('init-db')
|
||||
def init_db_command():
|
||||
init_db()
|
||||
print('Database initialized.')
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True, host='0.0.0.0')
|
||||
26
config.py
Normal file
26
config.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
|
||||
DATABASE = os.environ.get('DATABASE', '/app/data/booth.db')
|
||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
|
||||
ALLOWED_EXTENSIONS = {'mp3'}
|
||||
|
||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
|
||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
|
||||
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
|
||||
SMTP_PASS = os.environ.get('SMTP_PASS', '')
|
||||
SMTP_FROM = os.environ.get('SMTP_FROM', 'ai@hallsworth.ca')
|
||||
|
||||
ADMIN_PASSWORD_HASH = os.environ.get('ADMIN_PASSWORD_HASH', '')
|
||||
ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '')
|
||||
|
||||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')
|
||||
BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs')
|
||||
|
||||
# Price settings (informational, for receipt page)
|
||||
PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00'))
|
||||
CURRENCY = os.environ.get('CURRENCY', 'CAD')
|
||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
services:
|
||||
booth:
|
||||
build: .
|
||||
container_name: theme-song-booth
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
volumes:
|
||||
- /mnt/user/appdata/theme-song-booth/data:/app/data
|
||||
- /mnt/user/appdata/theme-song-booth/uploads:/app/uploads
|
||||
12
init_db.py
Normal file
12
init_db.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
# Ensure project root is importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app import app
|
||||
from models import init_db
|
||||
|
||||
with app.app_context():
|
||||
init_db()
|
||||
print(f"Database initialized at {app.config['DATABASE']}")
|
||||
94
models.py
Normal file
94
models.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import sqlite3
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from flask import current_app, g
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
hobbies TEXT,
|
||||
notable_facts TEXT,
|
||||
style_genre TEXT,
|
||||
extra_requests TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
suno_style TEXT,
|
||||
suno_lyrics TEXT,
|
||||
song_a_path TEXT,
|
||||
song_b_path TEXT,
|
||||
customer_approved TEXT DEFAULT 'none',
|
||||
approval_notified_at TIMESTAMP,
|
||||
preview_sent_at TIMESTAMP,
|
||||
delivery_sent_at TIMESTAMP,
|
||||
square_payment_ref TEXT,
|
||||
admin_alert_email TEXT,
|
||||
player_token TEXT NOT NULL UNIQUE,
|
||||
revision_note TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_requests_token ON requests(player_token);
|
||||
"""
|
||||
|
||||
def get_db():
|
||||
if 'db' not in g:
|
||||
g.db = sqlite3.connect(current_app.config['DATABASE'])
|
||||
g.db.row_factory = sqlite3.Row
|
||||
return g.db
|
||||
|
||||
def close_db(e=None):
|
||||
db = g.pop('db', None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
def init_db():
|
||||
db = sqlite3.connect(current_app.config['DATABASE'])
|
||||
db.executescript(SCHEMA)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
def new_token():
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
def now_utc():
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests):
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"""INSERT INTO requests
|
||||
(name, email, hobbies, notable_facts, style_genre, extra_requests, player_token)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, email, hobbies, notable_facts, style_genre, extra_requests, new_token())
|
||||
)
|
||||
db.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def get_request_by_id(request_id):
|
||||
db = get_db()
|
||||
row = db.execute('SELECT * FROM requests WHERE id = ?', (request_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_request_by_token(token):
|
||||
db = get_db()
|
||||
row = db.execute('SELECT * FROM requests WHERE player_token = ?', (token,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_requests(status=None):
|
||||
db = get_db()
|
||||
if status:
|
||||
rows = db.execute('SELECT * FROM requests WHERE status = ? ORDER BY created_at DESC', (status,)).fetchall()
|
||||
else:
|
||||
rows = db.execute('SELECT * FROM requests ORDER BY created_at DESC').fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def update_request(request_id, **fields):
|
||||
if not fields:
|
||||
return
|
||||
db = get_db()
|
||||
cols = ', '.join(f'{k} = ?' for k in fields)
|
||||
vals = list(fields.values()) + [request_id]
|
||||
db.execute(f'UPDATE requests SET {cols} WHERE id = ?', vals)
|
||||
db.commit()
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
flask
|
||||
gunicorn
|
||||
python-dotenv
|
||||
werkzeug
|
||||
78
templates/admin/dashboard.html
Normal file
78
templates/admin/dashboard.html
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Dashboard</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
||||
.container{max-width:1100px;margin:0 auto}
|
||||
h1{color:#60a5fa}
|
||||
.filters{margin-bottom:1rem}
|
||||
.filters a{color:#93c5fd;text-decoration:none;margin-right:1rem}
|
||||
.filters a.active{font-weight:bold;color:#fff}
|
||||
table{width:100%;border-collapse:collapse;background:#1f2937;border-radius:.5rem;overflow:hidden}
|
||||
th,td{padding:.7rem;text-align:left;border-bottom:1px solid #374151}
|
||||
th{background:#111827;color:#9ca3af}
|
||||
tr:hover{background:#2d3748}
|
||||
.status-badge{display:inline-block;padding:.25rem .6rem;border-radius:9999px;font-size:.8rem;font-weight:600;background:#374151}
|
||||
.awaiting_payment{background:#f59e0b;color:#000}
|
||||
.paid,.delivered{background:#10b981;color:#000}
|
||||
.pending,.prompt_ready{background:#60a5fa;color:#000}
|
||||
.songs_uploaded{background:#a78bfa;color:#000}
|
||||
.actions a{color:#93c5fd;text-decoration:none;margin-right:.8rem}
|
||||
.logout{float:right;color:#f87171;text-decoration:none}
|
||||
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem}
|
||||
.flash.error{background:#450a0a}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="{{ url_for('admin_logout') }}" class="logout">Log out</a>
|
||||
<h1>Theme Song Booth — Admin Dashboard</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="filters">
|
||||
<a href="{{ url_for('admin_dashboard') }}" class="{% if not current_status %}active{% endif %}">All</a>
|
||||
{% for key,label in statuses.items() %}
|
||||
<a href="{{ url_for('admin_dashboard', status=key) }}" class="{% if current_status == key %}active{% endif %}">{{ label }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Genre</th>
|
||||
<th>Status</th>
|
||||
<th>Approved</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in requests %}
|
||||
<tr>
|
||||
<td>#{{ r.id }}</td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.email }}</td>
|
||||
<td>{{ r.style_genre or '-' }}</td>
|
||||
<td><span class="status-badge {{ r.status }}">{{ statuses[r.status] }}</span></td>
|
||||
<td>{% if r.customer_approved != 'none' %}{{ r.customer_approved.upper() }}{% else %}-{% endif %}</td>
|
||||
<td class="actions"><a href="{{ url_for('admin_request', rid=r.id) }}">Open</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if not requests %}
|
||||
<tr><td colspan="7">No requests found.</td></tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
30
templates/admin/login.html
Normal file
30
templates/admin/login.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Login</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;display:flex;justify-content:center;align-items:center;min-height:100vh}
|
||||
form{background:#1f2937;padding:2rem;border-radius:1rem;width:100%;max-width:360px}
|
||||
h1{margin-top:0;color:#60a5fa}
|
||||
label{display:block;margin-top:1rem;font-weight:600}
|
||||
input{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box}
|
||||
button{margin-top:1.5rem;width:100%;padding:.8rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer}
|
||||
.flash{margin-top:1rem;color:#f87171}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form method="POST">
|
||||
<h1>Booth Admin</h1>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autofocus>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="flash">{{ messages[0] }}</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<button type="submit">Log In</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
125
templates/admin/request.html
Normal file
125
templates/admin/request.html
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Request #{{ req.id }} — Admin</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
||||
.container{max-width:900px;margin:0 auto}
|
||||
h1,h2{color:#60a5fa}
|
||||
a{color:#93c5fd}
|
||||
.section{background:#1f2937;padding:1rem;border-radius:.5rem;margin-bottom:1rem}
|
||||
label{display:block;margin-top:.8rem;font-weight:600}
|
||||
input,textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box;font:inherit}
|
||||
textarea{min-height:120px}
|
||||
button{padding:.7rem 1rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer;margin-top:.5rem}
|
||||
button.secondary{background:#4b5563}
|
||||
button.danger{background:#dc2626}
|
||||
button.success{background:#10b981}
|
||||
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem}
|
||||
.flash{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-bottom:1rem}
|
||||
.flash.error{background:#450a0a}
|
||||
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}
|
||||
.info-grid div{background:#111827;padding:.6rem;border-radius:.5rem}
|
||||
.approved-box{font-size:1.2rem;font-weight:bold;color:#fbbf24}
|
||||
.copy-hint{font-size:.85rem;color:#9ca3af;margin-top:.3rem}
|
||||
pre{background:#111827;padding:.8rem;border-radius:.5rem;overflow:auto;white-space:pre-wrap}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<p><a href="{{ url_for('admin_dashboard') }}">← Dashboard</a></p>
|
||||
<h1>Request #{{ req.id }} — {{ req.name }}</h1>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash {{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="section">
|
||||
<h2>Customer Info</h2>
|
||||
<div class="info-grid">
|
||||
<div><strong>Email:</strong> {{ req.email }}</div>
|
||||
<div><strong>Status:</strong> {{ statuses[req.status] }}</div>
|
||||
</div>
|
||||
<p><strong>Hobbies:</strong><br>{{ req.hobbies or '-' }}</p>
|
||||
<p><strong>Notable facts:</strong><br>{{ req.notable_facts or '-' }}</p>
|
||||
<p><strong>Style / genre:</strong><br>{{ req.style_genre or '-' }}</p>
|
||||
<p><strong>Extra requests:</strong><br>{{ req.extra_requests or '-' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Generate Suno Prompt</h2>
|
||||
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
|
||||
<p class="copy-hint">Paste the result from Hermes into the fields below, then click Save.</p>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="save_prompt">
|
||||
<label for="suno_style">Suno Style</label>
|
||||
<textarea id="suno_style" name="suno_style">{{ req.suno_style or '' }}</textarea>
|
||||
<label for="suno_lyrics">Suno Lyrics (with metatags)</label>
|
||||
<textarea id="suno_lyrics" name="suno_lyrics" rows="12">{{ req.suno_lyrics or '' }}</textarea>
|
||||
<div class="actions">
|
||||
<button type="submit">Save Prompt</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Upload Songs</h2>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="action" value="upload_songs">
|
||||
<label for="song_a">Version A MP3</label>
|
||||
<input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
|
||||
<label for="song_b">Version B MP3</label>
|
||||
<input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
|
||||
<div class="actions">
|
||||
<button type="submit">Upload Songs</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Notify Customer</h2>
|
||||
<p>Both songs must be uploaded first.</p>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="notify_customer">
|
||||
<button type="submit" {% if not (req.song_a_path and req.song_b_path) %}disabled{% endif %}>Send Preview Link</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Payment & Delivery</h2>
|
||||
<p>Customer approved:
|
||||
{% if req.customer_approved == 'none' %}
|
||||
<span class="approved-box">Nothing yet</span>
|
||||
{% else %}
|
||||
<span class="approved-box">{{ req.customer_approved.upper() }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="action" value="mark_paid_deliver">
|
||||
<label for="square_payment_ref">Square Payment Reference</label>
|
||||
<input type="text" id="square_payment_ref" name="square_payment_ref" placeholder="e.g. sq0idp-... or receipt number">
|
||||
<div class="actions">
|
||||
<button type="submit" class="success" {% if req.customer_approved == 'none' %}disabled{% endif %}>Mark Paid & Deliver</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyPromptForHermes() {
|
||||
const data = {
|
||||
name: {{ req.name | tojson }},
|
||||
hobbies: {{ req.hobbies | tojson }},
|
||||
notable_facts: {{ req.notable_facts | tojson }},
|
||||
style_genre: {{ req.style_genre | tojson }},
|
||||
extra_requests: {{ req.extra_requests | tojson }}
|
||||
};
|
||||
const text = "Please write a Suno Custom Mode prompt for this customer. Return ONLY a JSON object with keys: title, style, lyrics.\n\nCustomer data:\n" + JSON.stringify(data, null, 2);
|
||||
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes, then paste Hermes JSON result back into the Style/Lyrics fields."));
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
68
templates/player.html
Normal file
68
templates/player.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Your Theme Song</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
||||
.container{max-width:640px;margin:0 auto;background:#1f2937;padding:1.5rem;border-radius:1rem}
|
||||
h1{color:#60a5fa}
|
||||
.player{background:#111827;padding:1rem;border-radius:.5rem;margin:1rem 0}
|
||||
audio{width:100%;margin-top:.5rem}
|
||||
.actions{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1rem}
|
||||
button{flex:1;min-width:120px;padding:.8rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;cursor:pointer}
|
||||
button.selected{background:#10b981}
|
||||
button.both{background:#8b5cf6}
|
||||
button.revision{background:#f59e0b;color:#000}
|
||||
form{margin-top:1rem}
|
||||
textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box;min-height:80px}
|
||||
.status{padding:.8rem;background:#064e3b;border-radius:.5rem;margin-top:1rem}
|
||||
.status.waiting{background:#3f3f46}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎧 Your Custom Theme Song</h1>
|
||||
<p>Hi {{ req.name }}! Listen to both versions and pick the one you want.</p>
|
||||
|
||||
<div class="player">
|
||||
<h3>Version A</h3>
|
||||
<audio controls src="{{ url_for('audio', token=req.player_token, version='a') }}"></audio>
|
||||
</div>
|
||||
|
||||
<div class="player">
|
||||
<h3>Version B</h3>
|
||||
<audio controls src="{{ url_for('audio', token=req.player_token, version='b') }}"></audio>
|
||||
</div>
|
||||
|
||||
{% if req.status in ['songs_uploaded','awaiting_payment','paid','delivered'] %}
|
||||
<form method="POST" action="{{ url_for('approve', token=req.player_token) }}">
|
||||
<input type="hidden" name="choice" id="choice">
|
||||
<div class="actions">
|
||||
<button type="submit" class="{% if req.customer_approved == 'a' %}selected{% endif %}" onclick="document.getElementById('choice').value='a'">I want Version A</button>
|
||||
<button type="submit" class="{% if req.customer_approved == 'b' %}selected{% endif %}" onclick="document.getElementById('choice').value='b'">I want Version B</button>
|
||||
<button type="submit" class="both {% if req.customer_approved == 'both' %}selected{% endif %}" onclick="document.getElementById('choice').value='both'">I want both</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="{{ url_for('revise', token=req.player_token) }}">
|
||||
<label for="revision_note">Or ask for changes:</label>
|
||||
<textarea id="revision_note" name="revision_note" placeholder="e.g. make the chorus louder, swap a lyric..."></textarea>
|
||||
<div class="actions">
|
||||
<button type="submit" class="revision">Request Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if req.status == 'awaiting_payment' %}
|
||||
<div class="status waiting"><strong>Thanks for choosing {{ req.customer_approved.upper() }}!</strong> Please head to the booth to finalize payment and collect your files.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if req.status == 'delivered' %}
|
||||
<div class="status"><strong>Delivered! ✅</strong> Check your email for the MP3 attachment(s).</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
47
templates/request.html
Normal file
47
templates/request.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Request Your Theme Song</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
||||
.container{max-width:640px;margin:0 auto;background:#1f2937;padding:1.5rem;border-radius:1rem;box-shadow:0 10px 25px rgba(0,0,0,.3)}
|
||||
h1{margin-top:0;color:#60a5fa}
|
||||
label{display:block;margin-top:1rem;font-weight:600}
|
||||
input,textarea{width:100%;padding:.6rem;border-radius:.5rem;border:1px solid #374151;background:#111827;color:#f3f4f6;box-sizing:border-box;font:inherit}
|
||||
textarea{min-height:80px;resize:vertical}
|
||||
button{margin-top:1.5rem;width:100%;padding:.9rem;border:none;border-radius:.5rem;background:#3b82f6;color:#fff;font-weight:700;font-size:1.05rem;cursor:pointer}
|
||||
button:hover{background:#2563eb}
|
||||
.note{margin-top:1rem;font-size:.9rem;color:#9ca3af}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎵 Get Your Custom Theme Song</h1>
|
||||
<p>Tell us about yourself and we'll write a one-of-a-kind song for you.</p>
|
||||
<form method="POST">
|
||||
<label for="name">Name / persona you want in the song</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
|
||||
<label for="email">Email address</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
|
||||
<label for="hobbies">Hobbies & interests</label>
|
||||
<textarea id="hobbies" name="hobbies" placeholder="e.g. rock climbing, retro gaming, sourdough baking"></textarea>
|
||||
|
||||
<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"></textarea>
|
||||
|
||||
<label for="style_genre">Style / genre / mood</label>
|
||||
<input type="text" id="style_genre" name="style_genre" placeholder="e.g. 80s power ballad, cinematic orchestral, lo-fi synthwave">
|
||||
|
||||
<label for="extra_requests">Anything else you want in the song?</label>
|
||||
<textarea id="extra_requests" name="extra_requests" placeholder="Specific lyrics, vibe, clean/explicit, vocal gender..."></textarea>
|
||||
|
||||
<button type="submit">Submit Request</button>
|
||||
</form>
|
||||
<p class="note">Your info is only used to create and deliver your song.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
22
templates/thanks.html
Normal file
22
templates/thanks.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Request Received</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#111827;color:#f3f4f6;margin:0;padding:1rem;line-height:1.5}
|
||||
.container{max-width:640px;margin:0 auto;background:#1f2937;padding:1.5rem;border-radius:1rem;text-align:center}
|
||||
h1{color:#34d399}
|
||||
.token{font-family:monospace;background:#111827;padding:.6rem;border-radius:.5rem;word-break:break-all}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✅ Request Received</h1>
|
||||
<p>Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.</p>
|
||||
<p><strong>Your request number:</strong> #{{ req.id }}</p>
|
||||
<p class="note">Bring this number to the booth if you want to check on progress.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in a new issue