commit 99855b12120c6c848a1076f4b68ce8f23bddd52f Author: Troll (Hermes Agent) Date: Mon Aug 10 20:43:28 2026 +0000 Initial fork from theme-song-booth diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..585377c --- /dev/null +++ b/.env.example @@ -0,0 +1,45 @@ +# .env.example +# ============ +# +# Copy this file to .env for local development, or paste the values into +# Portainer when deploying the stack. +# +# Required for production: +# APP_SECRET_KEY - long random string used by Flask for sessions +# ADMIN_PASSWORD - password for the /admin dashboard +# SMTP_PASS - password for the SMTP account +# PUBLIC_BASE_URL - public HTTPS URL customers will use (e.g. https://booth.example.com) +# +# Optional: +# BOOTH_NAME - name shown in emails and customer text (default Trollgorithm Theme Songs) +# HOST_PORT - host-side port mapping for docker-compose (default 127.0.0.1:8000) +# INTERNAL_PORT - port gunicorn binds inside the container (default 8000) +# PRICE_PER_VERSION - shown on the receipt page (default 10.00) +# CURRENCY - currency label (default CAD) +# MAX_REVISIONS - default customer revision limit (default 2) +# HERMES_API_KEY - API key Hermes uses to POST prompts back (can be generated from /admin/settings) +# DATABASE - SQLite database path inside the container (default /app/data/booth.db) +# UPLOAD_FOLDER - directory for uploaded MP3s inside the container (default /app/uploads) +# SMTP_HOST - outgoing mail server (default mailroot8.namespro.ca) +# SMTP_PORT - outgoing mail server port (default 465) +# SMTP_USER - SMTP login username (default ai@hallsworth.ca) +# SMTP_FROM - From address for customer emails (default ai@hallsworth.ca) + +APP_SECRET_KEY=change-me-in-production +ADMIN_PASSWORD=change-me +SMTP_HOST=mailroot8.namespro.ca +SMTP_PORT=465 +SMTP_USER=ai@hallsworth.ca +SMTP_PASS= +SMTP_FROM=ai@hallsworth.ca +ADMIN_ALERT_EMAIL= +PUBLIC_BASE_URL=http://127.0.0.1:5000 +BOOTH_NAME=Trollgorithm Theme Songs +INTERNAL_PORT=8000 +HOST_PORT=127.0.0.1:8000 +PRICE_PER_VERSION=10.00 +CURRENCY=CAD +MAX_REVISIONS=2 +HERMES_API_KEY= +DATABASE=/app/data/booth.db +UPLOAD_FOLDER=/app/uploads diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bbcfaf --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +.env.local +__pycache__/ +*.pyc +.venv/ +data/ +uploads/ +*.db +*.mp3 +*.wav +.DS_Store diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..9ea302a --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,30 @@ +stages: + - check + - test + +syntax: + stage: check + image: python:3.12-slim + before_script: + - pip install --no-cache-dir -r requirements.txt + script: + - python -m py_compile app.py models.py config.py init_db.py + rules: + - if: '$CI_PIPELINE_SOURCE == "push"' + - if: '$CI_PIPELINE_SOURCE == "web"' + +pytest: + stage: test + image: python:3.12-slim + before_script: + - pip install --no-cache-dir -r requirements.txt pytest + script: + - | + if find . -type f \( -name "test_*.py" -o -name "*_test.py" \) | grep -q .; then + pytest + else + echo "No tests found; skipping." + fi + rules: + - if: '$CI_PIPELINE_SOURCE == "push"' + - if: '$CI_PIPELINE_SOURCE == "web"' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..62e6905 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +# Dockerfile +# ========== +# +# Builds the Theme Song Booth Flask app into a small production container. +# +# Steps: +# 1. Use Python 3.12 slim base image. +# 2. Install ffmpeg (used only if we later process audio metadata; harmless otherwise). +# 3. Install Python dependencies from requirements.txt. +# 4. Copy the entire repo into /app. +# 5. Create an unprivileged user (boothuser) and data/upload directories. +# 6. Expose the default internal port and run gunicorn on $INTERNAL_PORT. +# +# Environment: expects INTERNAL_PORT (default 8000) and the variables listed +# in config.py/.env.example to be supplied at runtime by docker-compose. + +FROM python:3.12-slim + +WORKDIR /app + +# Install ffmpeg; clean apt cache to keep image small. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Install Python requirements first for layer caching. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application source, templates, static files, etc. +COPY . . + +# Avoid writing .pyc files and ensure stdout is unbuffered. +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Create a non-root user, persistent directories, and fix ownership. +RUN useradd -m -u 1000 boothuser && mkdir -p /app/data /app/uploads && chown -R boothuser:boothuser /app +USER boothuser + +# Default internal port; override with INTERNAL_PORT env var. +EXPOSE 8000 + +# Use shell form so environment variables are expanded at runtime. +CMD gunicorn -b 0.0.0.0:${INTERNAL_PORT:-8000} --access-logfile - app:app diff --git a/README.md b/README.md new file mode 100644 index 0000000..147bbf8 --- /dev/null +++ b/README.md @@ -0,0 +1,270 @@ +# Theme Song Booth + +**Version:** `v0.6.3` + +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. + +--- + +## Table of contents + +1. [What the booth does](#what-the-booth-does) +2. [Customer-facing pages](#customer-facing-pages) +3. [Operator / admin pages](#operator--admin-pages) +4. [Status flow](#status-flow) +5. [Settings page explained](#settings-page-explained) +6. [Docker installation](#docker-installation) +7. [Environment variables](#environment-variables) +8. [File layout](#file-layout) +9. [Local development](#local-development) +10. [Common troubleshooting](#common-troubleshooting) +11. [License / ownership](#license--ownership) + +--- + +## What the booth does + +1. A visitor fills out a short form at `/request`. +2. The operator reviews the request in the admin dashboard and generates a Suno Custom Mode prompt. +3. The operator (or an AI assistant via the `/api/prompt` callback) saves the prompt to the request. +4. The operator creates two song versions in Suno, downloads them, and uploads **Version A** and **Version B** to the request page. +5. The operator sends a preview email; the customer visits their private player page, listens to both versions, and either approves one/both or requests changes. +6. After the customer approves and pays, the operator records the payment reference and delivers the selected MP3(s) by email. +7. Optional stems / extras can be delivered via a share link that appears on the player page after delivery. + +--- + +## Customer-facing pages + +| Page | Path | Purpose | +|------|------|---------| +| Request form | `/request` | Visitors enter email, name, pronouns, hobbies, notable facts, and extra requests. The music style is picked from three dropdowns: **Decade** (required), **Basic style** (required), and **Additional style** (optional). Decades and genres are loaded from `/mnt/Storage/Decades.txt` and `/mnt/Storage/Music Genres.txt`. Rate limited to 5 submissions per minute per IP. | +| Closed page | `/request` (when booth is closed) | Shows a friendly closed banner instead of the form when the operator marks the booth closed. | +| Thanks | `/thanks/` | Confirmation page shown after a request is submitted. | +| Order status | `/status` | Customers enter their email to see all their requests and statuses. | +| FAQ | `/faq` | Answers common customer questions. | +| Private player | `/play/` | Secret link emailed to the customer. Streams Version A and B, lets them approve or request revisions, and later download delivered files / stems. | +| Kiosk | `/kiosk` | Public full-screen display for a booth tablet. Cycles between a QR code for `/request` and the configured price list. Updates automatically when pricing or booth state changes. | + +### Style selection + +The request form no longer has a free-text genre field. Instead, customers choose: + +1. **Decade / era** — required (e.g. `1980's`). +2. **Basic style** — required (e.g. `Pop`). +3. **Additional style** — optional (e.g. `Funk`). + +These are stored together in the `style_genre` column as a comma-separated string (e.g. `1980's, Pop, Funk`) so no schema change is required. The admin request page shows the same dropdowns for corrections, and the copy-to-Hermes prompt formats the style as a clean sentence like "1980's-era Pop with Funk influences" for better Suno results. + +### Pronouns + +A required **Pronouns** dropdown is shown just below the name field, with options: + +- He/Him/His +- She/Her/Hers +- They/Them/Their + +The selected pronouns are stored in the `pronouns` column and included in confirmation emails and Hermes prompt copy. + +--- + +## Operator / admin pages + +| Page | Path | Purpose | +|------|------|---------| +| Login | `/admin/login` | Simple session-based login. Password comes from the `ADMIN_PASSWORD` environment variable. | +| Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. | +| Request detail | `/admin/request/` | Full control of one request: edit customer info (including pronouns and structured style), save prompt, copy Hermes callback, view revision history, upload MP3s, send preview, record payment, deliver files, add operator notes, and cancel. | +| Pricing | `/admin/pricing` | Configure fixed prices (one song, both songs, WAV per song, STEMs per song) and up to 5 custom items. | +| Sales | `/admin/sales` | Report of all delivered requests with customer details and Square payment references. | +| Settings | `/admin/settings` | Database health, backups, SMTP config, MP3 metadata defaults, revision limit, auto-refresh interval, kiosk mode, booth open/closed switch, Hermes API key management, and system reset. | +| Reset | `/admin/reset` | Clears all requests and uploaded files. Requires admin password confirmation. | + +--- + +## Status flow + +``` +pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered +``` + +| Status | Meaning | +|--------|---------| +| `pending` | Customer submitted; waiting for a Suno prompt. | +| `prompt_ready` | Prompt saved; ready to generate songs. | +| `songs_uploaded` | Both MP3s uploaded; preview link can be sent. | +| `revisions_requested` | Customer asked for changes; current files archived. | +| `awaiting_payment` | Customer approved a version; waiting for payment. | +| `paid` | Payment recorded. | +| `delivered` | Final MP3(s) emailed to the customer. | +| `cancelled` | Request cancelled by the operator. | + +--- + +## Settings page explained + +The `/admin/settings` page is split into functional sections: + +### Booth state +- **Booth open / closed** — When closed, `/request` and `/kiosk` show the closed banner. + +### Hermes API key +- Displays whether a key is configured. +- **Regenerate API Key** creates a new random key stored in runtime settings. +- The key is used by the `/api/prompt/` callback and by the `/api/key-test` diagnostic endpoint. +- Copy this key into your Hermes skill or AI assistant config. + +### Customer revision limit +- Maximum number of times a customer can click **Request Changes** on the player page. +- Default is controlled by `MAX_REVISIONS` env var; can be overridden here. + +### Dashboard refresh +- How often `/admin` reloads automatically (10, 20, or 30 seconds). + +### Kiosk display +- **QR only** — shows the QR code permanently. +- **Pricing only** — shows the price list permanently. +- **Cycle every N seconds** — alternates between QR and pricing. + +### SMTP settings +- Host, port, username, from address, and password for sending customer emails. +- The password is encrypted using `APP_SECRET_KEY` before being saved. +- **Send Test Email** verifies the configuration. + +### MP3 metadata defaults +- Artist, album, year, and comment tags applied automatically to uploaded MP3s. +- The title tag is taken from the saved Suno prompt. + +### Database maintenance +- **Health Check** — verifies all expected tables and columns exist. +- **Fix Database Schema** — adds missing tables/columns without deleting data. +- **Download Database Backup** — downloads the SQLite file. +- **Restore Database Backup** — replaces the live DB with an uploaded backup. +- **Download Uploads Backup** — ZIPs all uploaded MP3s for offline storage. +- **System Reset** — deletes all requests and uploaded files for a fresh event. + +--- + +## Docker installation + +### 1. Prepare environment variables + +Generate values for the required secrets: + +```bash +python3 -c "import secrets; print(secrets.token_hex(32))" +``` + +Use the output for `APP_SECRET_KEY`. + +### 2. Deploy with Portainer + +1. Log in to Portainer. +2. Go to **Stacks** → **Add stack**. +3. Choose **Repository**: + - URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git` + - Branch: `main` + - Compose path: `docker-compose.yml` +4. Add the environment variables listed in the section below. +5. Deploy the stack. +6. Open a console in the running `theme-song-booth` container and run once: + +```bash +python init_db.py +``` + +7. Point your reverse proxy at the host port you chose (default `127.0.0.1:8000`). +8. Visit `/admin/settings` and click **Regenerate API Key**. +9. Copy the key to your Hermes skill / AI assistant. +10. Print or display a QR code pointing to `https://your-domain/request`. + +### Updating the deployment + +After each push to Gitea: + +```text +Portainer → Stacks → theme-song-booth → Pull and redeploy +``` + +Persistent volumes keep the database and uploads safe across redeploys. + +--- + +## Environment variables + +| Variable | Required | Default | Description | +|----------|----------|---------|-------------| +| `APP_SECRET_KEY` | Yes | — | Long random string for Flask sessions and for encrypting stored settings such as the SMTP password and legacy stored API key. | +| `ADMIN_PASSWORD` | Yes | — | Password used to log in to `/admin`. | +| `SMTP_PASS` | Yes | — | Password for the SMTP account used to send customer emails. | +| `PUBLIC_BASE_URL` | Yes | — | Public HTTPS URL of the booth, e.g. `https://booth.dionysismedia.ca`. Used in player links, emails, and callback URLs. | +| `SMTP_HOST` | No | `mailroot8.namespro.ca` | SMTP server hostname. | +| `SMTP_PORT` | No | `465` | SMTP server port. | +| `SMTP_USER` | No | `ai@hallsworth.ca` | SMTP username. | +| `SMTP_FROM` | No | `ai@hallsworth.ca` | From address for customer emails. | +| `BOOTH_NAME` | No | `Trollgorithm Theme Songs` | Display name used in email subjects and page titles. | +| `HOST_PORT` | No | `127.0.0.1:8000` | Host-side `ip:port` mapping for the container. | +| `INTERNAL_PORT` | No | `8000` | Port gunicorn binds to inside the container. | +| `PRICE_PER_VERSION` | No | `10.00` | Legacy price label shown in some templates; current pricing is configured from `/admin/pricing`. | +| `CURRENCY` | No | `CAD` | Currency label shown with prices. | +| `MAX_REVISIONS` | No | `2` | Default customer revision limit before an operator override. | +| `HERMES_API_KEY` | No | — | API key for the `/api/prompt` callback. If omitted, generate one from `/admin/settings`. | +| `DATABASE` | No | `/app/data/booth.db` | Path to the SQLite database inside the container. | +| `UPLOAD_FOLDER` | No | `/app/uploads` | Path to uploaded MP3 storage inside the container. | + +--- + +## File layout + +| File | Purpose | +|------|---------| +| `app.py` | Flask routes, helpers, email layer, runtime settings, MP3 tagging, rate limiting, database maintenance, Hermes callback, kiosk, pricing, and sales report. | +| `config.py` | Environment-variable based configuration with sensible defaults. | +| `models.py` | SQLite schema, CRUD helpers, and revision history. | +| `init_db.py` | Standalone script to create or migrate the database. | +| `templates/` | Jinja2 templates for customer pages, admin pages, and kiosk display. | +| `static/` | Banner images, closed banner, email logo, and kiosk QR code. | +| `lists/` | Bundled copies of `decades.txt` and `music_genres.txt` used as fallback for the style dropdowns. | +| `Dockerfile` | Production container image definition. | +| `docker-compose.yml` | Portainer stack definition. | +| `requirements.txt` | Python dependencies. | +| `.env.example` | Local development environment template. | + +--- + +## 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, SMTP_PASS, PUBLIC_BASE_URL +.venv/bin/python init_db.py +.venv/bin/python -m flask --app app run --host=0.0.0.0 +``` + +Visit: +- Customer form: http://127.0.0.1:5000/request +- Admin login: http://127.0.0.1:5000/admin +- Kiosk: http://127.0.0.1:5000/kiosk + +--- + +## Common troubleshooting + +| Problem | Cause | Fix | +|---------|-------|-----| +| Emails not arriving | SMTP settings wrong or messages in spam. | Use **Send Test Email** on `/admin/settings`; verify host/port/password. | +| Can't reach app through domain | Reverse proxy points to wrong host port. | Match `HOST_PORT` to your proxy upstream. | +| Static banner not showing | Browser cached old image. | Hard-refresh or redeploy stack. | +| Logo missing from email | Logo file missing from `static/`. | Ensure `static/DM-Logo_email.png` is in the container. | +| Database schema mismatch | New column/table added but old DB not migrated. | Go to `/admin/settings` and click **Fix Database Schema**, or run `python init_db.py`. | +| Kiosk shows old prices | Page auto-refreshes every 30s; check `/admin/pricing`. | Verify pricing values and redeploy if templates changed. | +| `/api/prompt` returns 401 | Callback token expired or API key mismatch. | Copy a fresh callback URL from `/admin/request/` and verify the key with `/api/key-test`. | + +--- + +## License / ownership + +Built for Jess's Trollgorithm theme-song booth. All code and assets are private to that project. diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 0000000..8f975e8 --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,154 @@ +# REVIEW.md — Theme Song Booth + +Quick reference for future work on this project. + +## One-sentence summary + +Flask app that lets convention attendees request custom AI-generated theme songs, lets an operator manage the queue, and emails MP3s after payment. + +## Stack + +- Python 3.12 + Flask +- SQLite (file-based, request-scoped connection via `g`) +- Gunicorn in Docker +- Portainer stack deployed from Gitea repo +- SMTP (SSL port 465) for customer emails +- Square Terminal/Reader for manual payment +- `mutagen` for MP3 metadata tagging +- `flask-limiter` for public form rate limiting +- `cryptography` to encrypt the stored SMTP password + +## Repository + +- Gitea: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth` (public) +- Deployed at: `https://booth.dionysismedia.ca` + +## Key files and what they hold + +| File | Notes | +|---|---| +| `app.py` | All routes, helpers, email function, status labels, runtime settings, MP3 tagging, rate limiting, DB health, kiosk route, pricing route, sales report, ZIP backup, and the `/api/prompt/` Hermes callback endpoint. | +| `config.py` | Env vars. `ADMIN_PASSWORD` is plain text. `HERMES_API_KEY` can be overridden at runtime. | +| `models.py` | SQLite schema + CRUD. `player_token` is a secret URL-safe token. `revision_history` table tracks each customer revision. | +| `init_db.py` | Run once after deploy: `python init_db.py`. | +| `templates/admin/request.html` | Biggest template. Customer info editing, revision history, operator notes, Suno prompt fields, upload/delivery, cancel request button. | +| `templates/admin/dashboard.html` | Queue table + filters + auto-refresh + topbar links to Kiosk, Pricing, Sales, Settings. | +| `templates/admin/settings.html` | SMTP config, MP3 metadata defaults, DB backup/restore/health, reset, kiosk mode, Hermes API key display. | +| `templates/admin/pricing.html` | Fixed and custom pricing configuration. | +| `templates/admin/sales.html` | Sales report of delivered requests. | +| `templates/faq.html` | Customer FAQ page. | +| `templates/status.html` | Customer order status lookup. | +| `templates/closed.html` | Message shown on `/request` when the booth is marked closed. | +| `templates/kiosk.html` | Public full-screen display: open/closed banner, QR code, price list, auto-refresh. | +| `docker-compose.yml` | No `env_file`; variables come from Portainer. | + +## Status meanings + +``` +pending → prompt_ready → songs_uploaded → awaiting_payment → paid → delivered +``` + +Branch states: + +- `revisions_requested` — customer asked for changes; current files are archived and a note is logged. +- `cancelled` — operator cancelled the request. + +## Operator workflow + +1. Customer fills `/request`. +2. Open `/admin`, click request row (or filter by status). +3. On `/admin/request/`, fix customer info if needed, then click **Copy customer info for Hermes**, paste result to Hermes. +4. Hermes POSTs Title/Style/Lyrics back to the signed callback URL; the request becomes **Prompt Ready**. +5. If the callback fails, paste Hermes' response into the Title/Style/Lyrics fields and click **Save Prompt**. +6. Copy Lyrics, Style, Title into Suno Custom Mode in that order, generate two versions. +7. Upload Version A and B MP3s. +8. Click **Send Preview Link**. +9. Customer receives email, visits player, picks version. +10. Operator collects Square payment, enters reference, clicks **Mark Paid & Deliver**. +11. Customer receives MP3 attachment(s) by email. + +## Environment variables that matter + +``` +APP_SECRET_KEY +ADMIN_PASSWORD +SMTP_PASS +PUBLIC_BASE_URL +BOOTH_NAME +HOST_PORT +INTERNAL_PORT +MAX_REVISIONS +HERMES_API_KEY +``` + +Most can be overridden at runtime from `/admin/settings` and stored in `booth_settings.json`. + +## Runtime settings stored in `booth_settings.json` + +- SMTP host/port/user/from and encrypted password +- Dashboard auto-refresh interval +- Max revisions allowed per customer +- Booth open/closed state +- Default MP3 metadata tags (artist, album, year, comment) +- Hermes API key +- Pricing: fixed items (One Song, Both Songs, WAV per song, STEMs per song) and up to 5 custom named items +- Kiosk cycle mode (QR only, pricing only, or N seconds per slide) + +These survive redeploys because `booth_settings.json` lives in the persistent uploads volume. + +## Gotchas + +- Multiple forms on `admin/request.html` must stay properly closed; nested forms break buttons. +- `upload_songs` form needs `enctype="multipart/form-data"` and a matching ``. +- The dashboard uses `basename()` as a function, not a Jinja filter. +- Reset System deletes DB rows **and** all files under `UPLOAD_FOLDER`, then resets `sqlite_sequence`. +- Runtime settings are stored in the persistent uploads volume (`booth_settings.json`). +- The `booth_open` setting controls whether `/request` and `/kiosk` show the open banner or the closed banner. +- Container cannot read host paths; all static assets used at runtime (logo, banners, QR code) must be in `static/` or a mounted volume. +- The Hermes callback URL is signed with `APP_SECRET_KEY` and expires after 7 days. +- If you regenerate the Hermes API key, update the Hermes skill/config immediately; old key requests will 401. +- New columns/tables are added via `models.py`. Use `/admin/settings` → **Fix Database Schema** after redeploying a schema change. +- `__pycache__` and local `.env` files are already ignored by `.gitignore`; make sure they never get committed. + +## How to redeploy + +1. Push changes to Gitea `main`. +2. In Portainer: Stacks → `theme-song-booth` → **Pull and redeploy**. +3. If schema changed, open container console and run `python init_db.py`, or use `/admin/settings` → **Fix Database Schema**. + +## Recent major additions + +- **Structured style dropdowns** — customer form now uses Decade / Basic / Additional style dropdowns instead of a free-text genre field. Values are stored as a comma-separated string in `style_genre`. +- **Pronouns field** — required pronouns dropdown on the customer request form; stored in the `pronouns` column. +- **Lyrics in delivery email** — final delivery email includes the generated lyrics in the same format as the player page. +- **Delete uploaded songs** — admin request page can delete selected Version A / B uploads and reset the request to `prompt_ready`. +- **Live queue kiosk slide** — `/kiosk` can cycle through QR, pricing, and active-queue slides based on `kiosk_cycle_seconds`. +- **Cancelled status** — operators can mark any request as cancelled from the top of `/admin/request/`. +- **Revision history log** — each customer revision is recorded with revision count, note, and archived file names. +- **Stems / Extras link** — operators paste a file-share link on the request page; customers see a download button after delivery. +- **Sales report** — `/admin/sales` lists all delivered requests with payment references. +- **Pricing page** — `/admin/pricing` configures fixed prices plus up to 5 custom items; used by `/kiosk`. +- **Public kiosk** — `/kiosk` is a full-screen tablet display with QR code and price list cycling. +- **Music ZIP backup** — `/admin/settings` can download all uploaded MP3s as a ZIP. +- **Database schema repair** — health check detects missing columns and tables and can repair them. + +## Project state notes + +- No `.gitlab-ci.yml` is currently in the repo; old pipeline records from an earlier CI config are still visible in Gitea but are not actionable because no runners are attached. Add a CI skeleton (see below) if you want automated checks back. +- No automated tests exist yet. + +## CI skeleton (optional) + +A **CI skeleton** is the smallest Gitea CI config that gives you useful automated checks on every push without needing a heavy test suite. For this project it would be a `.gitlab-ci.yml` with one or two jobs: + +1. **Syntax check job** — install Python dependencies and run `python -m py_compile app.py models.py config.py init_db.py` to catch SyntaxErrors before they reach Portainer. +2. **(Optional) Test job** — run a minimal pytest suite once tests are written. Right now this would be a placeholder that skips if no tests exist, so the pipeline stays green while you decide whether to add tests. + +It needs a Gitea runner to execute. Your Gitea instance has no runners attached, which is why the old pipelines are stuck/canceled. The skeleton just defines *what* to run; a runner is still required for it to actually execute. + +## Static assets to keep in the repo + +- `static/Trollgorithm_booth.jpg` — open banner (request page and kiosk) +- `static/Booth_closed.png` — closed banner +- `static/DM-Logo_email.png` — email signature logo +- `static/qr-code.png` — kiosk QR code pointing to `/request` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..844f6a9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.6.3 diff --git a/app.py b/app.py new file mode 100644 index 0000000..8b32217 --- /dev/null +++ b/app.py @@ -0,0 +1,1348 @@ +""" +app.py +====== +Main Flask application for the Theme Song Booth. + +This module defines all HTTP routes, helper functions, the email layer, +runtime settings persistence, MP3 metadata tagging, rate limiting, and +database health/maintenance helpers. + +It is meant to be served by gunicorn inside a Docker container (see Dockerfile). + +Public routes (customers): +- / -> redirects to /request +- /request -> customer submits their info +- /thanks/ -> confirmation page after submission +- /play/ -> private player page with Version A and B +- /play//approve -> customer picks a version +- /play//revise -> customer asks for changes +- /audio//.mp3 -> serves the uploaded MP3 files + +Admin routes: +- /admin/login -> password login +- /admin/logout -> clears session +- /admin -> dashboard queue with status filters and auto-refresh +- /admin/settings -> runtime settings, health check, DB stats, backup/restore, reset +- /admin/request/ -> detail/edit page for a single request +- /admin/request//delete -> deletes one request and its uploaded files +- /admin/reset -> deletes ALL requests and ALL files +""" + +# Standard library imports +import os +import hmac +import shutil +import time +from pathlib import Path + +# Flask and related imports +from flask import Flask, request, render_template, redirect, url_for, flash, session, abort, current_app, send_file, jsonify +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +# Project imports +from config import Config +from models import init_db, close_db, create_request, get_request_by_id, get_request_by_token, list_requests, update_request, now_utc, delete_request, reset_all_requests, get_db +from models import SCHEMA, get_requests_by_email, log_revision, list_revision_history + +# Helpers (extracted from app.py to keep the route file manageable) +from helpers import ( + MUSIC_GENRES, DECADES, parse_style_genre, build_style_genre, + is_admin, require_admin, admin_password_ok, is_valid_email, + allowed_file, upload_path, save_upload, + apply_mp3_tags, + encrypt_value, decrypt_value, decrypt_value_legacy, + settings_file_path, load_booth_settings, save_booth_settings, + get_email_config, get_refresh_seconds, get_kiosk_cycle_seconds, get_kiosk_mode, + get_max_revisions, get_callback_expiry_hours, + get_hermes_api_key, set_hermes_api_key, generate_hermes_api_key, mask_api_key, + get_ntfy_config, send_ntfy, + sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url, + send_email, build_signature_images, + get_booth_open, +) + + +# --------------------------------------------------------------------------- +# App setup +# --------------------------------------------------------------------------- + +# Create the Flask app and load configuration from Config class. +app = Flask(__name__) +app.config.from_object(Config) + +# Global request rate limiting by remote IP (default 60/min). The public form is further limited to 5/min. +limiter = Limiter(get_remote_address, app=app, default_limits=["60 per minute"]) + +# Ensure the SQLite connection is closed at the end of each request. +app.teardown_appcontext(close_db) + +# Human-readable labels for each status value stored in the database. +STATUS_LABELS = { + 'pending': 'Pending', + 'prompt_ready': 'Prompt Ready', + 'songs_uploaded': 'Songs Uploaded — Awaiting Approval', + 'revisions_requested': 'Revisions Requested', + 'awaiting_payment': 'Awaiting Payment', + 'paid': 'Paid', + 'delivered': 'Delivered', + 'cancelled': 'Cancelled', +} + +# --------------------------------------------------------------------------- +# Public customer routes +# --------------------------------------------------------------------------- + +@app.route('/') +def index(): + """Root route: redirect customers straight to the request form.""" + return redirect(url_for('request_form')) + + +@app.route('/request', methods=['GET', 'POST']) +@limiter.limit("5 per minute") +def request_form(): + """ + Public request form. + GET -> shows the form with the banner image, or a closed message if the booth is closed. + POST -> validates the email, creates a database record, sends a + confirmation email, and redirects to the thanks page. + Rate limited to 5 submissions per minute per IP. + """ + if not get_booth_open(): + return render_template('closed.html') + + if request.method == 'POST': + decade = request.form.get('decade', '').strip() + basic_style = request.form.get('basic_style', '').strip() + additional_style = request.form.get('additional_style', '').strip() + pronouns = request.form.get('pronouns', '').strip() + if not pronouns: + flash('Please select your pronouns.', 'error') + return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 + if not decade: + flash('Please select a decade.', 'error') + return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 + if not basic_style: + flash('Please select a basic style.', 'error') + return render_template('request.html', form=request.form, decades=DECADES, genres=MUSIC_GENRES), 400 + form_data = { + 'name': request.form.get('name', '').strip(), + 'email': request.form.get('email', '').strip().lower(), + 'pronouns': pronouns, + 'hobbies': request.form.get('hobbies', '').strip()[:2000], + 'notable_facts': request.form.get('notable_facts', '').strip()[:2000], + 'style_genre': build_style_genre(decade, basic_style, additional_style), + 'extra_requests': request.form.get('extra_requests', '').strip()[:2000], + 'vocal_gender': request.form.get('vocal_gender', '').strip(), + 'stems_interest': bool(request.form.get('stems_interest')), + } + if not is_valid_email(form_data['email']): + flash('Please enter a valid email address.', 'error') + return render_template('request.html', form=form_data, decades=DECADES, genres=MUSIC_GENRES), 400 + rid = create_request(**form_data) + + # Notify operator via ntfy when a new request comes in. + try: + send_ntfy( + f"New request #{rid} from {form_data['name']} ({form_data['email']})\nStyle: {form_data['style_genre'] or '-'}", + title='New Theme Song Request', + priority='high', + tags='musical_note' + ) + except Exception: + # Push notification failure should never break the customer form. + pass + + # Send confirmation email with a summary of what the customer asked for. + req = get_request_by_id(rid) + if req: + try: + body_lines = [ + f"Hi {req['name']},", + "", + "Thanks for stopping by the Trollgorithm Theme Song Booth! We've received your request and will start crafting your custom song soon.", + "", + "Here's what we have on file:", + f"Name: {req['name']}", + f"Email: {req['email']}", + f"Pronouns: {req['pronouns'] or '-'}", + f"Style / genre: {req['style_genre'] or '-'}", + f"Preferred singer voice / gender: {req['vocal_gender'] or 'No preference'}", + f"Hobbies: {req['hobbies'] or '-'}", + f"Notable facts: {req['notable_facts'] or '-'}", + f"Extra requests: {req['extra_requests'] or '-'}", + f"Interested in STEMS: {'Yes' if req.get('stems_interest') else 'No'}", + "", + "You'll get another email with a private link to preview two versions of your song when they're ready.", + "", + "— Trollgorithm / Dionysis Media" + ] + send_email(req['email'], 'Your theme song request is received', '\n'.join(body_lines), inline_images=build_signature_images()) + except Exception as e: + flash(f'Your request was saved, but we could not send a confirmation email: {e}', 'error') + + flash('Your request has been submitted! Check your email soon.', 'success') + return redirect(url_for('thanks', rid=rid)) + return render_template('request.html', form=None, decades=DECADES, genres=MUSIC_GENRES) + + +@app.route('/api/ping', methods=['GET']) +@app.route('/api/key-test', methods=['GET']) +@limiter.limit('4 per minute') +def api_key_test(): + """ + Diagnostic endpoint for verifying the Hermes API key configuration. + + Accepts a Bearer token in the Authorization header and compares it against + the configured HERMES_API_KEY. Returns plain JSON so callers can distinguish + key mismatch from networking / signed-token issues. + + Rate limited to 4 per minute to prevent brute-force guessing. + """ + expected_key = get_hermes_api_key() + if not expected_key: + return jsonify({'ok': False, 'reason': 'not_configured'}), 500 + + auth_header = request.headers.get('Authorization', '').strip() + if not auth_header.startswith('Bearer '): + return jsonify({'ok': False, 'reason': 'missing_bearer'}), 401 + + provided_key = auth_header[7:].strip() + if not hmac.compare_digest(expected_key, provided_key): + return jsonify({'ok': False, 'reason': 'key_mismatch'}), 401 + + return jsonify({'ok': True, 'reason': 'valid'}), 200 + + +@app.route('/api/prompt/', methods=['POST']) +def api_update_prompt(rid): + """ + Hermes callback endpoint. + + Accepts a JSON POST with generated Suno prompt fields and updates the + matching request. Two layers of auth: + 1) A per-request signed callback token in the query string. + 2) A Hermes API key in the Authorization header (Bearer). + + Only records in 'pending' status can be updated. On success, status is set + to 'prompt_ready'. + """ + # Layer 1: verify the signed callback URL token. + callback_token = request.args.get('token', '').strip() + token_rid, token_ok = verify_prompt_callback(callback_token) + if not token_ok or token_rid != rid: + abort(401) + + # Layer 2: verify the Hermes API key from the Authorization header. + expected_key = get_hermes_api_key() + if not expected_key: + abort(500, description='Hermes API key is not configured') + auth_header = request.headers.get('Authorization', '').strip() + if not auth_header.startswith('Bearer '): + abort(401) + provided_key = auth_header[7:].strip() + if not hmac.compare_digest(expected_key, provided_key): + abort(401) + + req = get_request_by_id(rid) + if not req: + abort(404) + # Allow updates when the request is pending or when a revision has been requested. + if req['status'] not in ('pending', 'revisions_requested'): + abort(409, description='Request is no longer pending or awaiting revision') + + data = request.get_json(silent=True) or {} + + # Optional email verification to make sure clipboard matches record. + provided_email = data.get('email', '').strip().lower() + if provided_email and provided_email != req['email'].lower(): + abort(400, description='Email mismatch') + + # When this is a revision, keep the revision note and status as revisions_requested + # so the operator still sees it as "needs new songs", and preserve customer_approved. + new_status = 'revisions_requested' if req['status'] == 'revisions_requested' else 'prompt_ready' + update_request(rid, + suno_title=data.get('suno_title', '').strip(), + suno_style=data.get('suno_style', '').strip(), + suno_lyrics=data.get('suno_lyrics', '').strip(), + status=new_status + ) + + return jsonify({'ok': True, 'request_id': rid, 'status': new_status}), 200 + + +@app.route('/thanks/') +def thanks(rid): + """Confirmation page shown after a customer submits a request.""" + req = get_request_by_id(rid) + if not req: + abort(404) + return render_template('thanks.html', req=req) + + +@app.route('/status', methods=['GET', 'POST']) +def status_lookup(): + """ + Public order status lookup page. + Customers enter their email to see all their requests and their current + statuses, plus the private player link once songs have been uploaded. + """ + requests_list = [] + email = '' + searched = False + if request.method == 'POST': + email = request.form.get('email', '').strip().lower() + if not is_valid_email(email): + flash('Please enter a valid email address.', 'error') + else: + requests_list = get_requests_by_email(email) + searched = True + return render_template('status.html', email=email, requests=requests_list, searched=searched, statuses=STATUS_LABELS) + + +@app.route('/faq') +def faq(): + """Customer-facing frequently asked questions page.""" + return render_template('faq.html') + + +@app.route('/play/') +def play(token): + """ + Private player page for a customer. + The token is a cryptographically random URL-safe string generated at request time. + Shows A/B audio players, approval buttons, or a revision note depending on status. + """ + req = get_request_by_token(token) + if not req: + abort(404) + + # Load runtime max revisions setting. + max_revisions = get_max_revisions() + revisions_left = max(0, max_revisions - int(req.get('revision_count') or 0)) + + return render_template('player.html', req=req, revisions_left=revisions_left) + + +@app.route('/play//approve', methods=['POST']) +def approve(token): + """ + Customer has chosen Version A, Version B, or both. + Updates the request status to 'awaiting_payment' so the operator can collect payment. + Operator email alerts are intentionally disabled; the dashboard is the single queue. + """ + req = get_request_by_token(token) + if not req: + abort(404) + choice = request.form.get('choice') + if choice not in ('a', 'b', 'both'): + flash('Invalid selection.', 'error') + return redirect(url_for('play', token=token)) + + update_request(req['id'], customer_approved=choice, status='awaiting_payment', approval_notified_at=now_utc()) + + flash('Thanks! Please return to the booth to finalize payment.', 'success') + return redirect(url_for('play', token=token)) + + +@app.route('/play//revise', methods=['POST']) +def revise(token): + """ + Customer asked for changes. + Enforces the runtime max revisions limit, archives the current A/B MP3 files, + stores the revision note, and resets status to 'revisions_requested'. + """ + note = request.form.get('revision_note', '').strip() + req = get_request_by_token(token) + if not req: + abort(404) + + # Enforce max revisions limit for customer-submitted revisions. + max_revisions = get_max_revisions() + current_count = int(req.get('revision_count') or 0) + if current_count >= max_revisions: + flash('Revision limit reached. Please speak to the booth operator if you need further changes.', 'error') + return redirect(url_for('play', token=token)) + + # Increment revision counter and archive current files before new versions are uploaded. + new_count = current_count + 1 + upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(req['id']) + old_a, old_b = req.get('song_a_path'), req.get('song_b_path') + new_a, new_b = old_a, old_b + if upload_dir.exists(): + for field, version in (('song_a_path', 'A'), ('song_b_path', 'B')): + path = req.get(field) + if path and Path(path).exists(): + old = Path(path) + archived = upload_dir / f"Rev{new_count}-{old.name}" + try: + old.rename(archived) + if field == 'song_a_path': + new_a = str(archived) + else: + new_b = str(archived) + req[field] = str(archived) + except OSError: + pass + log_revision(req['id'], new_count, note, old_a=old_a, old_b=old_b, new_a=new_a, new_b=new_b) + update_request(req['id'], revision_note=note, status='revisions_requested', + song_a_path=req.get('song_a_path'), song_b_path=req.get('song_b_path'), + customer_approved='none', revision_count=int(new_count)) + + # NOTE: No operator email is sent; the dashboard is the single queue. + flash('Your feedback has been saved. We will regenerate and update you.', 'success') + return redirect(url_for('play', token=token)) + + +@app.route('/api/stream//.mp3') +def stream_audio(token, version): + """ + Stream an uploaded MP3 through a backend proxy endpoint. + + This hides the real file path from the customer. The endpoint checks the + player token and serves bytes with Range request support so the HTML audio + player can seek. The URL is still interceptable in-browser, but it is not a + direct file path and can be gated or expired later. + """ + req = get_request_by_token(token) + if not req: + abort(404) + if version not in ('a', 'b'): + abort(404) + + # Both versions must exist before any streaming happens. + a_path = req.get('song_a_path') + b_path = req.get('song_b_path') + if not a_path or not b_path: + abort(404) + for p in (a_path, b_path): + if not Path(p).exists(): + abort(404) + + path = a_path if version == 'a' else b_path + file_path = Path(path) + file_size = file_path.stat().st_size + + range_header = request.headers.get('Range', '') + start = 0 + end = file_size - 1 + status_code = 200 + + if range_header and range_header.startswith('bytes='): + try: + range_value = range_header[len('bytes='):].strip() + if '-' in range_value: + parts = range_value.split('-') + if parts[0]: + start = int(parts[0]) + if parts[1]: + end = min(int(parts[1]), file_size - 1) + if start >= file_size or start > end: + abort(416) + status_code = 206 + except ValueError: + start = 0 + end = file_size - 1 + status_code = 200 + + def generate(): + with open(file_path, 'rb') as f: + f.seek(start) + remaining = end - start + 1 + chunk_size = 64 * 1024 + while remaining > 0: + to_read = min(chunk_size, remaining) + data = f.read(to_read) + if not data: + break + yield data + remaining -= len(data) + + response = current_app.response_class(generate(), mimetype='audio/mpeg') + response.status_code = status_code + response.headers['Accept-Ranges'] = 'bytes' + response.headers['Content-Disposition'] = 'inline' + response.headers['Content-Length'] = str(end - start + 1) + if status_code == 206: + response.headers['Content-Range'] = f'bytes {start}-{end}/{file_size}' + return response + + +@app.route('/audio//.mp3') +def audio(token, version): + """ + Legacy audio endpoint. Replaced by /api/stream//.mp3. + Returns 404 so old direct links do not work. + """ + abort(404) + + +# --------------------------------------------------------------------------- +# Admin routes +# --------------------------------------------------------------------------- + +@app.route('/admin/login', methods=['GET', 'POST']) +def admin_login(): + """Simple session-based admin login. Password is set via ADMIN_PASSWORD env var.""" + if is_admin(): + return redirect(url_for('admin_dashboard')) + if request.method == 'POST': + if admin_password_ok(request.form.get('password', '')): + session['admin'] = True + return redirect(url_for('admin_dashboard')) + flash('Invalid password.', 'error') + return render_template('admin/login.html') + + +@app.route('/admin/logout') +def admin_logout(): + """Clear the admin session.""" + session.pop('admin', None) + return redirect(url_for('admin_login')) + + +@app.route('/admin') +def admin_dashboard(): + """ + Main operator queue. + Optional ?status= filter lets operators focus on one state at a time. + Auto-refresh interval is controlled from /admin/settings. + """ + redir = require_admin() + if redir: + return redir + status_filter = request.args.get('status') + requests = list_requests(status_filter) + return render_template('admin/dashboard.html', requests=requests, statuses=STATUS_LABELS, current_status=status_filter, refresh_seconds=get_refresh_seconds()) + + +@app.route('/admin/sales') +def admin_sales(): + """ + Sales report: delivered requests only. + Shows customer email, name, chosen version, and Square payment reference. + """ + redir = require_admin() + if redir: + return redir + sales = list_requests(status='delivered') + return render_template('admin/sales.html', sales=sales, statuses=STATUS_LABELS) + + +@app.route('/admin/pricing', methods=['GET', 'POST']) +def admin_pricing(): + """ + Pricing configuration page. + Fixed items: one_song, both_songs, wav_per_song, stems_per_song. + Plus up to 5 custom name/price pairs. + """ + redir = require_admin() + if redir: + return redir + + fixed_keys = ['one_song', 'both_songs', 'wav_per_song', 'stems_per_song'] + custom_count = 5 + + cfg = load_booth_settings() + if 'pricing' not in cfg: + cfg['pricing'] = {} + + if request.method == 'POST': + pricing = {} + for key in fixed_keys: + pricing[key] = request.form.get(key, '').strip() + for i in range(1, custom_count + 1): + name = request.form.get(f'custom_name_{i}', '').strip() + price = request.form.get(f'custom_price_{i}', '').strip() + if name: + pricing[f'custom_{i}'] = {'name': name, 'price': price} + else: + pricing[f'custom_{i}'] = None + cfg['pricing'] = pricing + save_booth_settings(cfg) + flash('Pricing saved.', 'success') + return redirect(url_for('admin_pricing')) + + pricing = cfg.get('pricing', {}) + fixed = {key: pricing.get(key, '') for key in fixed_keys} + customs = [] + for i in range(1, custom_count + 1): + entry = pricing.get(f'custom_{i}') + customs.append({ + 'name': entry.get('name', '') if isinstance(entry, dict) else '', + 'price': entry.get('price', '') if isinstance(entry, dict) else '' + }) + return render_template('admin/pricing.html', fixed=fixed, customs=customs) + + +@app.route('/kiosk') +def kiosk(): + """ + Public kiosk display page for the booth. + Shows open/closed banner, pricing, and optionally cycles with a QR code. + Auto-refreshes so pricing updates are picked up quickly. + """ + cfg = load_booth_settings() + pricing = cfg.get('pricing', {}) + + fixed_keys = { + 'one_song': 'One Song', + 'both_songs': 'Both Songs', + 'wav_per_song': 'WAV files / song', + 'stems_per_song': 'STEM files / song' + } + price_items = [] + for key, label in fixed_keys.items(): + val = pricing.get(key, '').strip() + if val: + price_items.append({'label': label, 'price': val}) + for i in range(1, 6): + entry = pricing.get(f'custom_{i}') + if isinstance(entry, dict): + name = entry.get('name', '').strip() + price = entry.get('price', '').strip() + if name and price: + price_items.append({'label': name, 'price': price}) + + cycle_seconds = get_kiosk_cycle_seconds() + mode = get_kiosk_mode() + booth_open = get_booth_open() + + # Build the public queue: only active statuses, mapped to friendly names, + # using the local-part of the email as the customer name. + KIOSK_STATUS_MAP = { + 'pending': 'Received', + 'prompt_ready': 'Trollgorithm Recording in Studio', + 'songs_uploaded': 'Trollgorithm Recording in Studio', + 'revisions_requested': 'Waiting for Customer Response', + 'awaiting_payment': 'Payment Due', + } + active_statuses = set(KIOSK_STATUS_MAP.keys()) + raw_queue = list_requests() + queue = [] + for r in raw_queue: + if r.get('status') in active_statuses: + email = r.get('email') or '' + name = email.split('@')[0] if '@' in email else email + queue.append({ + 'id': r['id'], + 'name': name or 'Guest', + 'status': KIOSK_STATUS_MAP[r['status']], + 'raw_status': r['status'], + }) + + return render_template( + 'kiosk.html', + booth_open=booth_open, + price_items=price_items, + cycle_seconds=cycle_seconds, + mode=mode, + queue=queue, + refresh_seconds=30 + ) + + +@app.route('/admin/request/', methods=['GET', 'POST']) +def admin_request(rid): + """ + Detail/edit page for a single request. + GET -> render customer info (email editable), prompt, upload status, + email status, operator notes, and delivery forms. + POST -> handle one of six actions: + update_customer_email, save_operator_notes, save_prompt, upload_songs, + notify_customer, mark_paid_deliver + Uploaded MP3s are tagged with metadata defaults from /admin/settings. + """ + redir = require_admin() + if redir: + return redir + req = get_request_by_id(rid) + if not req: + abort(404) + + # Small helpers exposed to the template for status badges. + def file_exists(path): + return bool(path and Path(path).exists()) + + def basename(path): + return Path(path).name if path else '' + + # Collect any extra MP3 files in the request folder (archived revisions). + upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid) + current_paths = {req['song_a_path'], req['song_b_path']} + extra_files = [] + if upload_dir.exists(): + for f in upload_dir.iterdir(): + if f.is_file() and f.suffix.lower() == '.mp3' and str(f) not in current_paths: + extra_files.append(str(f)) + extra_files.sort() + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'update_customer_email': + new_email = request.form.get('email', '').strip().lower() + if not is_valid_email(new_email): + flash('Please enter a valid email address.', 'error') + return redirect(url_for('admin_request', rid=rid)) + update_request(rid, email=new_email) + flash('Customer email updated.', 'success') + return redirect(url_for('admin_request', rid=rid)) + + elif action == 'update_customer_info': + new_email = request.form.get('email', '').strip().lower() + pronouns = request.form.get('pronouns', '').strip() + if not is_valid_email(new_email): + flash('Please enter a valid email address.', 'error') + return redirect(url_for('admin_request', rid=rid)) + if not pronouns: + flash('Pronouns are required.', 'error') + return redirect(url_for('admin_request', rid=rid)) + style_genre = build_style_genre( + request.form.get('decade', '').strip(), + request.form.get('basic_style', '').strip(), + request.form.get('additional_style', '').strip() + ) + update_request(rid, + email=new_email, + name=request.form.get('name', '').strip(), + pronouns=pronouns, + hobbies=request.form.get('hobbies', '').strip(), + notable_facts=request.form.get('notable_facts', '').strip(), + style_genre=style_genre, + vocal_gender=request.form.get('vocal_gender', '').strip(), + extra_requests=request.form.get('extra_requests', '').strip(), + stems_interest=bool(request.form.get('stems_interest')) + ) + flash('Customer info updated.', 'success') + return redirect(url_for('admin_request', rid=rid)) + + elif action == 'save_operator_notes': + update_request(rid, operator_notes=request.form.get('operator_notes', '').strip()) + flash('Operator notes saved.', 'success') + return redirect(url_for('admin_request', rid=rid)) + + elif action == 'save_stems_link': + update_request(rid, stems_link=request.form.get('stems_link', '').strip()) + flash('Stems share link saved.', 'success') + return redirect(url_for('admin_request', rid=rid)) + + elif action == 'cancel_request': + update_request(rid, status='cancelled') + flash('Request marked as cancelled.', 'success') + return redirect(url_for('admin_request', rid=rid)) + + elif action == 'save_prompt': + # Store the generated title/style/lyrics and mark prompt ready. + update_request(rid, + suno_title=request.form.get('suno_title', '').strip(), + suno_style=request.form.get('suno_style', '').strip(), + suno_lyrics=request.form.get('suno_lyrics', '').strip(), + status='prompt_ready' + ) + flash('Prompt saved.', 'success') + + elif action == 'delete_songs': + # Remove selected uploaded MP3s and reset status so new songs can be uploaded. + to_delete = request.form.getlist('delete_song') + if not to_delete: + flash('Select at least one song to delete.', 'error') + return redirect(url_for('admin_request', rid=rid)) + + update_fields = {} + for version in to_delete: + if version == 'a': + path = req.get('song_a_path') + if path and Path(path).exists(): + try: + Path(path).unlink() + except OSError: + pass + update_fields['song_a_path'] = None + elif version == 'b': + path = req.get('song_b_path') + if path and Path(path).exists(): + try: + Path(path).unlink() + except OSError: + pass + update_fields['song_b_path'] = None + + if update_fields: + # Wipe any prior customer approval because the old files are gone. + update_fields['customer_approved'] = 'none' + # Reset to prompt_ready since songs need to be uploaded again. + update_fields['status'] = 'prompt_ready' + update_request(rid, **update_fields) + flash('Selected songs deleted. Request reset to Prompt Ready.', 'success') + else: + flash('No uploaded songs selected for deletion.', 'error') + + elif action == 'upload_songs': + # Save uploaded MP3 files for Version A and/or Version B. + # Use the saved Suno title as the MP3 title tag if available. + song_title = req.get('suno_title') or None + a_path = save_upload(rid, request.files.get('song_a'), 'a', song_title) + b_path = save_upload(rid, request.files.get('song_b'), 'b', song_title) + fields = {} + if a_path: + fields['song_a_path'] = a_path + if b_path: + fields['song_b_path'] = b_path + if fields: + fields['status'] = 'songs_uploaded' + update_request(rid, **fields) + flash('Songs uploaded.', 'success') + + elif action == 'notify_customer': + # Email the customer a private player link. Both songs must be uploaded first. + if not (req['song_a_path'] and req['song_b_path']): + flash('Both songs must be uploaded first.', 'error') + else: + player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}" + body = f"Hi {req['name']},\n\nYour custom theme song has been created. Listen to both versions and let us know which one you want:\n\n{player_link}\n\n- Version A\n- Version B\n- Or both versions\n\nOnce you make your choice, we'll send you to the booth to finalize payment and deliver your files.\n\nThanks for stopping by!\n\n— {current_app.config['BOOTH_NAME']}" + try: + send_email(req['email'], 'Your custom theme song is ready — listen and pick your version', body, inline_images=build_signature_images()) + update_request(rid, preview_sent_at=now_utc(), status='songs_uploaded') + flash('Preview email sent.', 'success') + except Exception as e: + flash(f'Failed to send preview email: {e}', 'error') + + elif action == 'update_payment_ref': + # Update the Square payment reference without sending email or changing status. + payment_ref = request.form.get('square_payment_ref', '').strip() + update_request(rid, square_payment_ref=payment_ref) + flash('Payment reference updated.', 'success') + return redirect(url_for('admin_request', rid=rid)) + + elif action == 'mark_paid_deliver': + # Finalize: record Square payment ref, attach approved MP3s, email customer. + if req.get('customer_approved', 'none') == 'none': + flash('Customer must approve a version before you can mark paid or deliver.', 'error') + return redirect(url_for('admin_request', rid=rid)) + + payment_ref = request.form.get('square_payment_ref', '').strip() + if not payment_ref: + flash('Square payment reference is required.', 'error') + return redirect(url_for('admin_request', rid=rid)) + + # Build list of selected files from checkboxes. + selected = request.form.getlist('deliver_file') + if not selected: + flash('Select at least one file to deliver.', 'error') + return redirect(url_for('admin_request', rid=rid)) + + # Record payment immediately so the reference is preserved even if email fails. + update_request(rid, square_payment_ref=payment_ref, status='delivered') + + attachments = [] + for path in selected: + p = Path(path) + if p.exists(): + attachments.append((str(p), p.name)) + + # Compute 3-month expiry date for any stems share link. + from datetime import datetime, timedelta + expiry_date = (datetime.utcnow() + timedelta(days=90)).strftime('%B %d, %Y') + + player_link = f"{current_app.config['PUBLIC_BASE_URL']}/play/{req['player_token']}" + + # Format lyrics the same way they appear on the customer player page: + # skip section headers like [Chorus] and blank lines, keep stanza breaks. + lyrics_section = '' + if req.get('suno_lyrics'): + lyric_lines = [] + for line in req['suno_lyrics'].splitlines(): + stripped = line.strip() + if stripped and not (stripped.startswith('[') and stripped.endswith(']')): + lyric_lines.append(stripped) + elif not stripped and lyric_lines and lyric_lines[-1] != '': + lyric_lines.append('') + if lyric_lines: + lyrics_section = '\n'.join(['', '---', 'Song Lyrics', '---', ''] + lyric_lines + ['']) + + body_lines = [ + f"Hi {req['name']},", + "", + "Thanks for your payment! Your selected song(s) are attached to this email.", + f"You can also keep streaming them here: {player_link}", + ] + if lyrics_section: + body_lines.append(lyrics_section) + body_lines += [ + "", + "Enjoy!", + "", + f"— {current_app.config['BOOTH_NAME']}" + ] + if req.get('stems_link'): + body_lines.insert(4, f"Your stems / extras are available here: {req['stems_link']}") + body_lines.insert(5, f"This share link expires on {expiry_date} (3 months from today). Please download before then.") + body_lines.insert(6, "") + body = '\n'.join(body_lines) + try: + send_email(req['email'], 'Your theme song files are here!', body, attachments=attachments, inline_images=build_signature_images()) + update_request(rid, delivery_sent_at=now_utc()) + flash('Delivery email sent with MP3 attachments.', 'success') + except Exception as e: + flash(f'Payment recorded, but delivery email failed: {e}', 'error') + + return redirect(url_for('admin_request', rid=rid)) + + return render_template( + 'admin/request.html', + req=req, + style_parts=parse_style_genre(req.get('style_genre')), + decades=DECADES, + genres=MUSIC_GENRES, + statuses=STATUS_LABELS, + file_exists=file_exists, + basename=basename, + extra_files=extra_files, + callback_url=build_prompt_callback_url(rid), + revision_history=list_revision_history(rid) + ) + + +@app.route('/admin/request//delete', methods=['POST']) +def admin_delete_request(rid): + """Delete a single request and remove its uploaded MP3 files.""" + redir = require_admin() + if redir: + return redir + req = get_request_by_id(rid) + if not req: + abort(404) + + # Delete uploaded files if they exist. + for field in ('song_a_path', 'song_b_path'): + path = req.get(field) + if path and Path(path).exists(): + try: + Path(path).unlink() + except OSError: + pass + # Remove empty upload directory. + upload_dir = Path(current_app.config['UPLOAD_FOLDER']) / str(rid) + if upload_dir.exists(): + try: + upload_dir.rmdir() + except OSError: + pass + + delete_request(rid) + flash(f'Request #{rid} deleted.', 'success') + return redirect(url_for('admin_dashboard')) + + +@app.route('/admin/settings', methods=['GET', 'POST']) +def admin_settings(): + """ + Settings / maintenance page for operators. + GET -> show database health, statistics, disk usage, runtime settings forms, + booth open/closed switch, SMTP/email config, MP3 metadata defaults, backup/restore, and reset. + POST -> handle one of: fix_db, reset_system, save_max_revisions, save_metadata, + save_email_config, send_test_email, save_refresh, save_booth_open, download_db, restore_db. + """ + redir = require_admin() + if redir: + return redir + + db_path = Path(current_app.config['DATABASE']) + upload_root = Path(current_app.config['UPLOAD_FOLDER']) + + # Compute database stats. + db_size = db_path.stat().st_size if db_path.exists() else 0 + all_requests = list_requests() + total_records = len(all_requests) + status_counts = {} + for req in all_requests: + status_counts[req['status']] = status_counts.get(req['status'], 0) + 1 + + # Load persistent runtime settings (max_revisions overrides env var if set). + runtime_settings = load_booth_settings() + current_max_revisions = get_max_revisions() + current_refresh_seconds = runtime_settings.get('refresh_seconds', 10) + booth_open = runtime_settings.get('booth_open', True) + + # Effective email config to show in the form (non-sensitive only; password left blank). + email_form = { + 'smtp_host': runtime_settings.get('smtp_host', current_app.config['SMTP_HOST']), + 'smtp_port': runtime_settings.get('smtp_port', str(current_app.config['SMTP_PORT'])), + 'smtp_user': runtime_settings.get('smtp_user', current_app.config['SMTP_USER']), + 'smtp_from': runtime_settings.get('smtp_from', current_app.config['SMTP_FROM']), + 'smtp_pass_set': bool(runtime_settings.get('smtp_pass', '')), + } + + # Hermes API key state for the settings page. + hermes_key = get_hermes_api_key() + hermes_key_masked = mask_api_key(hermes_key) + hermes_key_set = bool(hermes_key) + hermes_key_just_generated = session.pop('hermes_key_just_generated', None) + + # Compute upload folder stats. + total_upload_size = 0 + upload_file_count = 0 + request_dir_count = 0 + if upload_root.exists(): + for entry in upload_root.iterdir(): + if entry.is_dir(): + request_dir_count += 1 + for f in entry.iterdir(): + if f.is_file(): + total_upload_size += f.stat().st_size + upload_file_count += 1 + elif entry.is_file(): + total_upload_size += entry.stat().st_size + upload_file_count += 1 + + def format_bytes(n): + for unit in ['B', 'KB', 'MB', 'GB']: + if n < 1024: + return f"{n:.2f} {unit}" + n /= 1024 + return f"{n:.2f} TB" + + # Health check: verify expected columns exist and expected tables exist. + expected_cols = { + 'id', 'created_at', 'name', 'email', 'hobbies', 'notable_facts', + 'style_genre', 'extra_requests', 'vocal_gender', 'status', 'suno_title', 'suno_style', + 'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved', + 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', + 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count', 'operator_notes', 'stems_link', 'stems_interest' + } + expected_tables = {'requests', 'revision_history'} + health = {'ok': True, 'missing_columns': [], 'missing_tables': [], 'message': 'Database schema looks good.'} + try: + db = get_db() + cur = db.execute("SELECT name FROM sqlite_master WHERE type='table'") + existing_tables = {row['name'] for row in cur.fetchall()} + missing_tables = sorted(expected_tables - existing_tables) + + cur = db.execute('PRAGMA table_info(requests)') + existing_cols = {row['name'] for row in cur.fetchall()} + missing_cols = sorted(expected_cols - existing_cols) + + if missing_tables or missing_cols: + parts = [] + if missing_tables: + parts.append(f"missing tables: {', '.join(missing_tables)}") + if missing_cols: + parts.append(f"missing columns: {', '.join(missing_cols)}") + health = {'ok': False, 'missing_columns': missing_cols, 'missing_tables': missing_tables, 'message': 'Database schema issues: ' + '; '.join(parts)} + except Exception as e: + health = {'ok': False, 'missing_columns': [], 'missing_tables': [], 'message': f'Could not inspect database: {e}'} + + if request.method == 'POST': + action = request.form.get('action') + + if action == 'fix_db': + # Attempt to create missing tables and add missing columns via ALTER TABLE. + try: + db = get_db() + db.executescript(SCHEMA) + if not health['ok'] and health['missing_columns']: + for col in health['missing_columns']: + # Default to TEXT columns; adequate for current schema. + db.execute(f'ALTER TABLE requests ADD COLUMN {col} TEXT') + flash(f'Created missing tables and added columns: {", ".join(health["missing_columns"])}. Please refresh the page.', 'success') + else: + flash('Database schema is up to date.', 'success') + db.commit() + except Exception as e: + flash(f'Failed to fix database: {e}', 'error') + return redirect(url_for('admin_settings')) + + elif action == 'reset_system': + # Same nuclear reset logic as the old /admin/reset endpoint. + if upload_root.exists(): + for entry in upload_root.iterdir(): + try: + if entry.is_file(): + entry.unlink() + elif entry.is_dir(): + shutil.rmtree(entry) + except OSError: + pass + reset_all_requests() + flash('System reset complete. All orders and files have been cleared.', 'success') + return redirect(url_for('admin_settings')) + + elif action == 'save_max_revisions': + # Update the MAX_REVISIONS config from the settings form. + try: + val = int(request.form.get('max_revisions', '2').strip()) + if val < 0: + raise ValueError + cfg = load_booth_settings() + cfg['max_revisions'] = val + save_booth_settings(cfg) + flash(f'Maximum revisions set to {val}.', 'success') + except ValueError: + flash('Invalid revision limit. Please enter a non-negative number.', 'error') + return redirect(url_for('admin_settings')) + + elif action == 'save_metadata': + # Update MP3 metadata defaults from the settings form. + # Store empty strings (not None) so fields repopulate correctly on reload. + cfg = load_booth_settings() + for key in ('artist', 'album', 'year', 'comment'): + cfg[key] = request.form.get(key, '').strip() + save_booth_settings(cfg) + flash('MP3 metadata defaults saved.', 'success') + return redirect(url_for('admin_settings')) + + elif action == 'save_email_config': + # Update SMTP settings from the settings form. Password is encrypted. + # Empty values are stored as empty strings so the form repopulates. + cfg = load_booth_settings() + cfg['smtp_host'] = request.form.get('smtp_host', '').strip() + cfg['smtp_port'] = request.form.get('smtp_port', '').strip() + cfg['smtp_user'] = request.form.get('smtp_user', '').strip() + cfg['smtp_from'] = request.form.get('smtp_from', '').strip() + new_pass = request.form.get('smtp_pass', '').strip() + # Only overwrite the stored password if a new value was provided. + if new_pass: + cfg['smtp_pass'] = encrypt_value(new_pass) + save_booth_settings(cfg) + flash('Email (SMTP) settings saved. Password stored encrypted.', 'success') + return redirect(url_for('admin_settings')) + + elif action == 'send_test_email': + # Send a test email to the address provided in the form. + test_to = request.form.get('test_email_address', '').strip() + if not test_to: + flash('Enter a test email address first.', 'error') + return redirect(url_for('admin_settings')) + try: + body = "Hi,\n\nThis is a test email from the Trollgorithm Theme Song Booth. If you're seeing this, SMTP is configured correctly." + send_email(test_to, 'SMTP Test from Theme Song Booth', body, inline_images=build_signature_images()) + flash(f'Test email sent to {test_to}.', 'success') + except Exception as e: + flash(f'Failed to send test email: {e}', 'error') + return redirect(url_for('admin_settings')) + + elif action == 'save_refresh': + # Update dashboard auto-refresh interval. + val = request.form.get('refresh_seconds', '10').strip() + if val not in ('0', '10', '20', '30'): + val = '10' + cfg = load_booth_settings() + cfg['refresh_seconds'] = int(val) + save_booth_settings(cfg) + flash(f'Dashboard auto-refresh set to {val} seconds.', 'success') + return redirect(url_for('admin_settings')) + + elif action == 'save_kiosk_cycle': + # Update kiosk slide cycle interval. Special values: + # -1 = show QR only, 0 = show pricing only, 5+ = cycle every N seconds. + raw = request.form.get('kiosk_cycle_seconds', '10').strip() + try: + val = int(raw) + except ValueError: + val = 10 + if val not in (-1, 0) and val < 5: + val = 5 + cfg = load_booth_settings() + cfg['kiosk_cycle_seconds'] = val + save_booth_settings(cfg) + if val == -1: + label = 'QR code only' + elif val == 0: + label = 'pricing only' + else: + label = f'cycle every {val} seconds' + flash(f'Kiosk mode set to {label}.', 'success') + return redirect(url_for('admin_settings')) + + elif action == 'save_booth_open': + # Toggle whether the public request form is accepting submissions. + cfg = load_booth_settings() + cfg['booth_open'] = request.form.get('booth_open', '1') == '1' + save_booth_settings(cfg) + state = 'open' if cfg['booth_open'] else 'closed' + flash(f'Booth is now {state}.', 'success') + return redirect(url_for('admin_settings')) + + elif action == 'download_db': + # Send the SQLite database file as a download. + if db_path.exists(): + return send_file(str(db_path), as_attachment=True, download_name='theme-song-booth.db') + flash('Database file not found.', 'error') + return redirect(url_for('admin_settings')) + + elif action == 'download_uploads_zip': + # Zip all files under UPLOAD_FOLDER and send as a download. + import zipfile + zip_path = db_path.with_suffix('.uploads-' + str(int(time.time())) + '.zip') + try: + with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf: + if upload_root.exists(): + for entry in upload_root.rglob('*'): + if entry.is_file(): + zf.write(str(entry), str(entry.relative_to(upload_root))) + return send_file(str(zip_path), as_attachment=True, download_name='theme-song-booth-uploads.zip') + except Exception as e: + flash(f'Failed to create uploads ZIP: {e}', 'error') + return redirect(url_for('admin_settings')) + finally: + if zip_path.exists(): + zip_path.unlink() + + elif action == 'restore_db': + # Replace the current database file with an uploaded SQLite backup. + file_obj = request.files.get('db_backup') + if not file_obj or file_obj.filename == '': + flash('No database backup file selected.', 'error') + return redirect(url_for('admin_settings')) + backup_path = db_path.with_suffix('.backup-restore') + try: + # Stream uploaded file directly to disk to avoid memory issues with large DBs. + file_obj.save(backup_path) + # Quick sanity check: try to open as SQLite and query sqlite_master. + import sqlite3 + conn = sqlite3.connect(str(backup_path)) + conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + conn.close() + # Replace old database with backup. + old_backup = db_path.with_suffix('.backup-' + str(int(time.time()))) + db_path.rename(old_backup) + backup_path.rename(db_path) + flash('Database restored successfully. Old database kept at ' + old_backup.name, 'success') + except Exception as e: + if backup_path.exists(): + backup_path.unlink() + flash(f'Database restore failed: {e}', 'error') + return redirect(url_for('admin_settings')) + + elif action == 'save_callback_expiry': + # Update the Hermes callback URL expiry lifetime in hours. + try: + val = int(request.form.get('callback_expiry_hours', '168').strip()) + if val < 1: + raise ValueError + cfg = load_booth_settings() + cfg['callback_expiry_hours'] = val + save_booth_settings(cfg) + flash(f'Callback link expiry set to {val} hour(s).', 'success') + except ValueError: + flash('Invalid callback expiry. Please enter a positive number of hours.', 'error') + return redirect(url_for('admin_settings')) + + elif action == 'regenerate_hermes_key': + # Legacy action: no longer exposed in UI. Key is managed via HERMES_API_KEY env var. + flash('Hermes API key is managed via the HERMES_API_KEY environment variable.', 'info') + return redirect(url_for('admin_settings')) + + elif action == 'save_ntfy': + # Update ntfy push notification server/topic/access token from the settings form. + cfg = load_booth_settings() + cfg['ntfy_server'] = request.form.get('ntfy_server', '').strip().rstrip('/') + cfg['ntfy_topic'] = request.form.get('ntfy_topic', '').strip() + new_token = request.form.get('ntfy_token', '').strip() + if new_token: + cfg['ntfy_token'] = encrypt_value(new_token) + save_booth_settings(cfg) + flash('ntfy notification settings saved.', 'success') + return redirect(url_for('admin_settings')) + + elif action == 'send_test_ntfy': + # Send a test push notification to the configured ntfy topic. + ntfy = get_ntfy_config() + if not ntfy['server'] or not ntfy['topic']: + flash('Configure ntfy server and topic first.', 'error') + return redirect(url_for('admin_settings')) + ok = send_ntfy('This is a test push notification from the Trollgorithm Theme Song Booth.', title='Test Notification', priority='high', tags='test_tube') + if ok: + flash('Test ntfy notification sent.', 'success') + else: + flash('Failed to send ntfy test notification. Check server, topic, and access token.', 'error') + return redirect(url_for('admin_settings')) + + return render_template( + 'admin/settings.html', + health=health, + db_size=format_bytes(db_size), + total_records=total_records, + status_counts=status_counts, + statuses=STATUS_LABELS, + upload_file_count=upload_file_count, + upload_dir_count=request_dir_count, + upload_size=format_bytes(total_upload_size), + db_path=str(db_path), + upload_path=str(upload_root), + current_max_revisions=current_max_revisions, + current_refresh_seconds=current_refresh_seconds, + current_kiosk_cycle_seconds=get_kiosk_cycle_seconds(), + booth_open=booth_open, + email_form=email_form, + metadata={ + 'artist': runtime_settings.get('artist', ''), + 'album': runtime_settings.get('album', ''), + 'year': runtime_settings.get('year', ''), + 'comment': runtime_settings.get('comment', ''), + }, + hermes_key_masked=hermes_key_masked, + hermes_key_set=hermes_key_set, + hermes_key_just_generated=hermes_key_just_generated, + version=current_app.config['VERSION'], + current_callback_expiry_hours=get_callback_expiry_hours(), + ntfy=runtime_settings, + ) + + +@app.route('/admin/reset', methods=['POST']) +def admin_reset_system(): + """ + Nuclear reset for the start of an event. + Deletes all database rows and all files/directories under UPLOAD_FOLDER. + Requires clicking through a browser confirm dialog. + """ + redir = require_admin() + if redir: + return redir + + upload_root = Path(current_app.config['UPLOAD_FOLDER']) + if upload_root.exists(): + for entry in upload_root.iterdir(): + try: + if entry.is_file(): + entry.unlink() + elif entry.is_dir(): + shutil.rmtree(entry) + except OSError: + pass + + reset_all_requests() + flash('System reset complete. All orders and files have been cleared.', 'success') + return redirect(url_for('admin_dashboard')) + + +# --------------------------------------------------------------------------- +# CLI and entry point +# --------------------------------------------------------------------------- + +@app.cli.command('init-db') +def init_db_command(): + """Flask CLI command: flask --app app init-db""" + init_db() + print('Database initialized.') + + +if __name__ == '__main__': + # Development-only entry point. Production uses gunicorn (see Dockerfile). + app.run(debug=True, host='0.0.0.0') + + +# Ensure the database file and expected tables exist when the app is imported by +# gunicorn in production. init_db() uses CREATE TABLE IF NOT EXISTS, so this is +# safe to run on every startup without wiping data. +with app.app_context(): + try: + init_db() + except Exception: + # If the database path is not yet reachable (e.g. volume not mounted), + # defer to the first request or the explicit init-db command. + import logging + logging.getLogger('app').warning('Startup init_db() failed; database may need manual initialization.', exc_info=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..9aacaf2 --- /dev/null +++ b/config.py @@ -0,0 +1,99 @@ +""" +config.py +========= +Configuration object loaded by Flask from environment variables. + +Most operational settings are editable at runtime from /admin/settings and +stored in booth_settings.json on disk. Sensitive values (SMTP password) are +encrypted with the Flask SECRET_KEY when saved. + +Environment variables (defaults shown): + Required: + - APP_SECRET_KEY long random string for Flask sessions and encryption + - ADMIN_PASSWORD plain-text password for /admin login + - PUBLIC_BASE_URL public URL customers use (e.g. https://booth.example.com) + - SMTP_PASS password for the SMTP account + Optional: + - BOOTH_NAME name in customer text and emails (default Trollgorithm Theme Songs) + - HOST_PORT docker-compose host-side port mapping (default 127.0.0.1:8000) + - INTERNAL_PORT gunicorn port inside the container (default 8000) + - MAX_REVISIONS default customer revision limit (default 2) + - PRICE_PER_VERSION price shown to customers (default 10.00) + - CURRENCY currency label (default CAD) + - DATABASE SQLite database path inside the container (default /app/data/booth.db) + - UPLOAD_FOLDER directory for uploaded MP3s inside the container (default /app/uploads) + - SETTINGS_FILE runtime settings JSON filename (default booth_settings.json) + - SMTP_HOST outgoing mail server (default mailroot8.namespro.ca) + - SMTP_PORT outgoing mail server port (default 465) + - SMTP_USER SMTP login username (default ai@hallsworth.ca) + - SMTP_FROM From address for customer emails (default ai@hallsworth.ca) +""" + +import os +from dotenv import load_dotenv + +from pathlib import Path + +# Load variables from .env file if present (development mode). +load_dotenv() + + +def _load_version(): + """Read the package version from the VERSION file next to this module.""" + version_file = Path(__file__).parent / 'VERSION' + if version_file.exists(): + return version_file.read_text().strip() + return '0.0.0' + + +class Config: + # Flask secret key: used to sign session cookies and encrypt stored credentials. + SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me') + + # SQLite database path inside the container. + DATABASE = os.environ.get('DATABASE', '/app/data/booth.db') + + # Directory where uploaded MP3 files are stored inside the container. + UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads') + + # Runtime settings file: stored next to the upload folder for persistence. + SETTINGS_FILE = os.environ.get('SETTINGS_FILE', 'booth_settings.json') + + # Only MP3 uploads are allowed. + ALLOWED_EXTENSIONS = {'mp3'} + + # Default SMTP server settings for sending customer emails. + # These can be overridden from /admin/settings and stored encrypted. + 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 login password (plain text, set via env). + ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '') + + # Public HTTPS URL used in customer emails and QR codes. + PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000') + + # Default customer revision limit if not overridden in runtime settings. + MAX_REVISIONS = int(os.environ.get('MAX_REVISIONS', '2')) + + # API key used by Hermes / an AI assistant to POST generated Suno prompts + # back to /api/prompt/. If provided via env var it overrides the value + # stored in runtime settings. Stored encrypted when set from /admin/settings. + HERMES_API_KEY = os.environ.get('HERMES_API_KEY', '') + + # Booth name used in customer-facing text and email sign-offs. + BOOTH_NAME = os.environ.get('BOOTH_NAME', 'Trollgorithm Theme Songs') + + # Internal port gunicorn listens on inside the container (also exposed in Dockerfile). + INTERNAL_PORT = int(os.environ.get('INTERNAL_PORT', '8000')) + + # Legacy single-price label. Current pricing is configured per-item from + # /admin/pricing, but this value is still displayed in a few templates. + PRICE_PER_VERSION = float(os.environ.get('PRICE_PER_VERSION', '10.00')) + CURRENCY = os.environ.get('CURRENCY', 'CAD') + + # Package version, read from VERSION file. + VERSION = _load_version() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5fc2916 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +# docker-compose.yml +# ================== +# +# Portainer stack definition. +# Builds the image directly from the Gitea repository (main branch). +# +# Environment variables are set in Portainer under the stack's +# Environment variables section. The container uses them directly, +# so no .env file is required on disk. +# +# Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL +# Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION, +# CURRENCY, MAX_REVISIONS, HERMES_API_KEY, DATABASE, UPLOAD_FOLDER, +# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM +# +# Named volumes keep the SQLite database and uploaded MP3s persistent +# across container restarts and redeploys. + +services: + booth: + build: + context: https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git#main + container_name: theme-song-booth + restart: unless-stopped + environment: + - APP_SECRET_KEY=${APP_SECRET_KEY} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - SMTP_HOST=${SMTP_HOST:-mailroot8.namespro.ca} + - SMTP_PORT=${SMTP_PORT:-465} + - SMTP_USER=${SMTP_USER:-ai@hallsworth.ca} + - SMTP_PASS=${SMTP_PASS} + - SMTP_FROM=${SMTP_FROM:-ai@hallsworth.ca} + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL} + - BOOTH_NAME=${BOOTH_NAME:-Trollgorithm Theme Songs} + - INTERNAL_PORT=${INTERNAL_PORT:-8000} + - MAX_REVISIONS=${MAX_REVISIONS:-2} + - HERMES_API_KEY=${HERMES_API_KEY:-} + - PRICE_PER_VERSION=${PRICE_PER_VERSION:-10.00} + - CURRENCY=${CURRENCY:-CAD} + - DATABASE=${DATABASE:-/app/data/booth.db} + - UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads} + ports: + - "${HOST_PORT:-127.0.0.1:8000}:${INTERNAL_PORT:-8000}" + volumes: + - booth-data:/app/data + - booth-uploads:/app/uploads + +volumes: + booth-data: + booth-uploads: diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..d0804eb --- /dev/null +++ b/helpers.py @@ -0,0 +1,502 @@ +""" +helpers.py +========== +Utility and configuration helpers for the Theme Song Booth Flask app. + +These functions are stateless (or use Flask's current_app / session context) +and are imported by app.py. Keeping them here reduces the size of the route file. +""" + +import os +import re +import shutil +import smtplib +import ssl +import time +import base64 +import hmac +import hashlib +import secrets +import json +from email.message import EmailMessage +from pathlib import Path + +from flask import session, current_app, flash +from werkzeug.utils import secure_filename + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +from mutagen.mp3 import MP3 +from mutagen.easyid3 import EasyID3 + +import requests + + +# --------------------------------------------------------------------------- +# Genre / decade helpers +# --------------------------------------------------------------------------- + +_GENRES_PATH = Path('/mnt/Storage/Music Genres.txt') +_DECADES_PATH = Path('/mnt/Storage/Decades.txt') +_FALLBACK_GENRES_PATH = Path(__file__).parent / 'lists' / 'music_genres.txt' +_FALLBACK_DECADES_PATH = Path(__file__).parent / 'lists' / 'decades.txt' + + +def _load_lines(path: Path) -> list[str]: + """Load a text file and return non-empty stripped lines.""" + if not path.exists(): + return [] + lines = path.read_text(encoding='utf-8').splitlines() + return [line.strip() for line in lines if line.strip()] + + +def _load_list(primary: Path, fallback: Path) -> list[str]: + """Load from the primary path, falling back to the bundled copy.""" + lines = _load_lines(primary) + if lines: + return lines + return _load_lines(fallback) + + +MUSIC_GENRES = _load_list(_GENRES_PATH, _FALLBACK_GENRES_PATH) +DECADES = _load_list(_DECADES_PATH, _FALLBACK_DECADES_PATH) + + +def parse_style_genre(style_genre: str | None) -> dict: + """ + Split a stored combined style string into decade, basic, and additional. + The stored format is 'Decade, Basic, Additional' (additional may be empty). + """ + parts = [p.strip() for p in (style_genre or '').split(',') if p.strip()] + return { + 'decade': parts[0] if len(parts) > 0 else '', + 'basic_style': parts[1] if len(parts) > 1 else '', + 'additional_style': ', '.join(parts[2:]) if len(parts) > 2 else '', + } + + +def build_style_genre(decade: str, basic_style: str, additional_style: str) -> str: + """Build the combined style_genre string stored in the database.""" + parts = [p.strip() for p in [decade, basic_style, additional_style] if p.strip()] + return ', '.join(parts) + + +# --------------------------------------------------------------------------- +# Auth / validation helpers +# --------------------------------------------------------------------------- + +def is_admin(): + """Return True if the current browser session is logged in as admin.""" + return session.get('admin') is True + + +def require_admin(): + """Redirect to the admin login page if the user is not logged in.""" + from flask import redirect, url_for + if not is_admin(): + return redirect(url_for('admin_login')) + + +def admin_password_ok(pw): + """Check the submitted admin password against the configured one.""" + return pw and pw == current_app.config['ADMIN_PASSWORD'] + + +def is_valid_email(email): + """Return True if the given string looks like a valid email address.""" + if not email: + return False + pattern = r"^[^\s@]+@[^\s@]+\.[^\s@]+$" + return re.match(pattern, email) is not None + + +# --------------------------------------------------------------------------- +# File upload helpers +# --------------------------------------------------------------------------- + +def allowed_file(filename): + """Return True if the uploaded filename has an allowed extension (mp3).""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in current_app.config['ALLOWED_EXTENSIONS'] + + +def upload_path(request_id): + """Return the per-request upload directory path, creating it if necessary.""" + p = Path(current_app.config['UPLOAD_FOLDER']) / str(request_id) + p.mkdir(parents=True, exist_ok=True) + return p + + +def save_upload(request_id, file_obj, version, song_title=None): + """ + Save an uploaded MP3 file for a request, preserving the original filename + with a version prefix. Applies the configured metadata tags. + """ + if not file_obj or file_obj.filename == '': + return None + if not allowed_file(file_obj.filename): + flash('Only MP3 files are allowed.', 'error') + return None + original = secure_filename(file_obj.filename) + filename = f"{version.upper()} - {original}" + p = upload_path(request_id) + dest = p / filename + file_obj.save(dest) + apply_mp3_tags(str(dest), song_title) + return str(dest) + + +def apply_mp3_tags(path, title=None): + """ + Write common ID3 tags on an uploaded MP3 using the runtime metadata defaults. + Failures are logged as a warning and do not block the upload. + """ + cfg = load_booth_settings() + try: + audio = MP3(path) + if audio.tags is None: + audio.add_tags() + if not isinstance(audio.tags, EasyID3): + audio.tags = EasyID3() + tags = audio.tags + if title: + tags['title'] = title + if cfg.get('artist'): + tags['artist'] = cfg['artist'] + if cfg.get('album'): + tags['album'] = cfg['album'] + if cfg.get('year'): + tags['date'] = str(cfg['year']) + audio.save() + if cfg.get('comment'): + from mutagen.id3 import COMM, TXXX + audio2 = MP3(path) + if audio2.tags is None: + audio2.add_tags() + audio2.tags["COMM"] = COMM(encoding=3, lang='eng', desc='Comment', text=cfg['comment']) + audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment']) + audio2.save() + except Exception as e: + try: + flash(f'Warning: could not tag MP3: {e}', 'error') + except RuntimeError: + import logging + logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e) + + +# --------------------------------------------------------------------------- +# Encryption / settings helpers +# --------------------------------------------------------------------------- + +def _get_fernet(): + """Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted.""" + secret = current_app.config['SECRET_KEY'].encode() + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b'theme-song-booth-v1', + iterations=480000, + ) + key = base64.urlsafe_b64encode(kdf.derive(secret)) + return Fernet(key) + + +def encrypt_value(value): + """Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext.""" + if not value: + return '' + return _get_fernet().encrypt(value.encode()).decode() + + +def decrypt_value(ciphertext): + """Decrypt a string previously encrypted by encrypt_value.""" + if not ciphertext: + return '' + try: + return _get_fernet().decrypt(ciphertext.encode()).decode() + except Exception: + return '' + + +def decrypt_value_legacy(ciphertext): + """Decrypt or return plaintext. Tolerates unencrypted legacy values.""" + if not ciphertext: + return '' + plaintext = decrypt_value(ciphertext) + if plaintext: + return plaintext + if not ciphertext.endswith('='): + return ciphertext + return '' + + +def settings_file_path(): + """Return the path to the persistent runtime settings JSON file.""" + return Path(current_app.config['DATABASE']).parent / current_app.config['SETTINGS_FILE'] + + +def load_booth_settings(): + """Load persistent runtime settings from JSON file inside the upload parent.""" + cfg_path = settings_file_path() + if cfg_path.exists(): + try: + cfg = json.loads(cfg_path.read_text()) + for key in ('artist', 'album', 'year', 'comment', 'smtp_host', 'smtp_port', 'smtp_user', 'smtp_from'): + if cfg.get(key) is None: + cfg[key] = '' + return cfg + except (json.JSONDecodeError, OSError): + pass + return {} + + +def save_booth_settings(settings): + """Persist runtime settings to JSON file.""" + cfg_path = settings_file_path() + try: + cfg_path.write_text(json.dumps(settings, indent=2)) + except OSError as e: + flash(f'Warning: could not save settings: {e}', 'error') + + +# --------------------------------------------------------------------------- +# Config getters +# --------------------------------------------------------------------------- + +def get_email_config(): + """Return the effective SMTP configuration.""" + cfg = load_booth_settings() + return { + 'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']), + 'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']), + 'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']), + 'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'], + 'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']), + } + + +def get_refresh_seconds(): + """Return the dashboard auto-refresh interval in seconds (10, 20, or 30).""" + cfg = load_booth_settings() + try: + val = int(cfg.get('refresh_seconds', 10)) + except (ValueError, TypeError): + val = 10 + return val if val in (10, 20, 30) else 10 + + +def get_kiosk_cycle_seconds(): + """Return the kiosk slide cycle interval in seconds.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('kiosk_cycle_seconds', 10)) + except (ValueError, TypeError): + val = 10 + if val == 0: + return 0 + return max(5, val) + + +def get_kiosk_mode(): + """Return 'qr', 'prices', 'queue', or 'cycle' based on kiosk_cycle_seconds setting.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('kiosk_cycle_seconds', 10)) + except (ValueError, TypeError): + val = 10 + if val == -1: + return 'qr' + if val == 0: + return 'prices' + if val == 1: + return 'queue' + return 'cycle' + + +def get_max_revisions(): + """Return the effective max revisions as an integer.""" + cfg = load_booth_settings() + try: + val = int(cfg.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2))) + except (ValueError, TypeError): + val = current_app.config.get('MAX_REVISIONS', 2) + return max(0, val) + + +def get_callback_expiry_hours(): + """Return the Hermes signed callback token lifetime in hours (default 168 = 7 days).""" + cfg = load_booth_settings() + try: + val = int(cfg.get('callback_expiry_hours', 168)) + except (ValueError, TypeError): + val = 168 + return max(1, val) + + +def get_hermes_api_key(): + """Return the effective Hermes API key.""" + env_key = current_app.config.get('HERMES_API_KEY', '') + if env_key: + return env_key + cfg = load_booth_settings() + return decrypt_value_legacy(cfg.get('hermes_api_key', '')) + + +def set_hermes_api_key(key): + """Persist a new Hermes API key (encrypted) to runtime settings.""" + cfg = load_booth_settings() + cfg['hermes_api_key'] = encrypt_value(key) + save_booth_settings(cfg) + + +def generate_hermes_api_key(): + """Generate a new random API key for Hermes callback authentication.""" + return secrets.token_urlsafe(32) + + +def mask_api_key(key): + """Return a masked version of the API key showing only the last 6 characters.""" + if not key: + return 'Not set' + if len(key) <= 6: + return '*' * len(key) + return '*' * (len(key) - 6) + key[-6:] + + +def get_ntfy_config(): + """Return the effective ntfy server URL, topic, and access token from runtime settings.""" + cfg = load_booth_settings() + return { + 'server': cfg.get('ntfy_server', ''), + 'topic': cfg.get('ntfy_topic', ''), + 'token': decrypt_value(cfg.get('ntfy_token', '')) or '', + } + + +def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'): + """Send a push notification to the configured ntfy topic, if configured.""" + ntfy = get_ntfy_config() + server = ntfy.get('server', '').rstrip('/') + topic = ntfy.get('topic', '').strip() + if not server or not topic: + return False + + url = f"{server}/{topic}" + headers = { + 'Title': title, + 'Priority': priority, + 'Tags': tags, + } + token = ntfy.get('token', '') + if token: + headers['Authorization'] = f'Bearer {token}' + try: + resp = requests.post(url, data=message.encode('utf-8'), headers=headers, timeout=10) + return resp.status_code in (200, 202) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Signed callback helpers +# --------------------------------------------------------------------------- + +def sign_prompt_callback(rid, expires_at=None): + """ + Create a signed callback token for a specific request ID. + The signature is HMAC-SHA256 over "rid:expires_at" using APP_SECRET_KEY. + """ + secret = current_app.config['SECRET_KEY'].encode() + if expires_at is None: + expires_at = int(time.time()) + get_callback_expiry_hours() * 3600 + payload = f"{rid}:{expires_at}" + sig = hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()[:16] + return f"{rid}:{expires_at}:{sig}" + + +def verify_prompt_callback(token): + """Verify a signed callback token. Returns (rid, ok) tuple.""" + if not token: + return None, False + parts = token.split(':') + if len(parts) != 3: + return None, False + try: + rid = int(parts[0]) + expires_at = int(parts[1]) + except ValueError: + return None, False + if int(time.time()) > expires_at: + return None, False + expected = sign_prompt_callback(rid, expires_at) + if not hmac.compare_digest(expected, token): + return None, False + return rid, True + + +def build_prompt_callback_url(rid): + """Build the full callback URL an operator pastes into Hermes for a request.""" + from flask import url_for + token = sign_prompt_callback(rid) + return f"{current_app.config['PUBLIC_BASE_URL']}/api/prompt/{rid}?token={token}" + + +# --------------------------------------------------------------------------- +# Email helpers +# --------------------------------------------------------------------------- + +def send_email(to, subject, body, attachments=None, inline_images=None): + """Send an email using the configured or runtime SMTP settings.""" + cfg = get_email_config() + if not cfg['SMTP_PASS']: + raise RuntimeError('SMTP password is not configured') + + msg = EmailMessage() + msg['From'] = cfg['SMTP_FROM'] + msg['To'] = to + msg['Subject'] = subject + msg.set_content(body) + + html_body = body.replace('\n', '
\n') + if inline_images: + for _, cid in inline_images: + html_body += f'
Dionysis Media' + html_body += f'


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

' + msg.add_alternative(html_body, subtype='html') + + if inline_images: + for path, cid in inline_images: + with open(path, 'rb') as f: + data = f.read() + ext = Path(path).suffix.lower().lstrip('.') + subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png' + msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>') + + if attachments: + for path, name in attachments: + with open(path, 'rb') as f: + data = f.read() + msg.add_attachment(data, maintype='audio', subtype='mpeg', filename=name) + + with smtplib.SMTP_SSL(cfg['SMTP_HOST'], cfg['SMTP_PORT'], context=ssl.create_default_context()) as server: + server.login(cfg['SMTP_USER'], cfg['SMTP_PASS']) + server.send_message(msg) + + +def build_signature_images(): + """Return inline image tuple list for static/DM-Logo_email.png.""" + logo_path = Path(current_app.root_path) / 'static' / 'DM-Logo_email.png' + if not logo_path.exists(): + return [] + return [(str(logo_path), 'dm-logo')] + + +# --------------------------------------------------------------------------- +# Booth state +# --------------------------------------------------------------------------- + +def get_booth_open(): + """Return True if the booth is currently marked as open in runtime settings.""" + cfg = load_booth_settings() + return cfg.get('booth_open', True) diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..a337c38 --- /dev/null +++ b/init_db.py @@ -0,0 +1,25 @@ +""" +init_db.py +========== +Standalone script to create the SQLite database tables. + +Run this once inside the container after deployment: + python init_db.py + +Or use the registered Flask CLI command: + flask --app app init-db +""" + +import os +import sys + +# Ensure the project root is on sys.path so `from app import app` works. +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app import app +from models import init_db + +# Use the configured database path and create tables. +with app.app_context(): + init_db() + print(f"Database initialized at {app.config['DATABASE']}") diff --git a/lists/decades.txt b/lists/decades.txt new file mode 100755 index 0000000..4423cda --- /dev/null +++ b/lists/decades.txt @@ -0,0 +1,10 @@ +Early 20th Centrury +1940's +1950's +1960's +1970's +1980's +1990's +2000's +2010's +Modern diff --git a/lists/music_genres.txt b/lists/music_genres.txt new file mode 100755 index 0000000..9a894a4 --- /dev/null +++ b/lists/music_genres.txt @@ -0,0 +1,190 @@ +A Cappella +Abstract +Acid +Acid Jazz +Acid Punk +Acoustic +Afro-Punk +Alternative +Alternative Rock +Ambient +Anime +Art Rock +Avantgarde +Ballad +Baroque +Bass +Beat +Bebop +Bhangra +Big Band +Big Beat +Black Metal +Bluegrass +Blues +Booty Bass +Breakbeat +BritPop +Cabaret +Celtic +Chamber Music +Chanson +Chillout +Chorus +Christian Gangsta Rap +Christian Rap +Christian Rock +Classic Rock +Classical +Club +Club-House +Comedy +Contemporary Christian +Country +Crossover +Cult +Dance +Dance Hall +Darkwave +Death Metal +Disco +Downtempo +Dream +Drum & Bass +Drum Solo +Dub +Dubstep +Duet +Easy Listening +EBM +Eclectic +Electro +Electroclash +Electronic +Emo +Ethnic +Euro-House +Euro-Techno +Eurodance +Experimental +Fast Fusion +Folk +Folk-Rock +Folklore +Freestyle +Funk +Fusion +G-Funk +Game +Gangsta +Garage +Garage Rock +Global +Goa +Gospel +Gothic +Gothic Rock +Grunge +Hard Rock +Hardcore +Heavy Metal +Hip-Hop +House +Humour +IDM +Illbient +Indie +Indie Rock +Industrial +Industro-Goth +Instrumental +Instrumental Pop +Instrumental Rock +Jam Band +Jazz +Jazz & Funk +JPop +Jungle +Krautrock +Latin +Leftfield +Lo-Fi +Lounge +Math Rock +Mariachi +Meditative +Merengue +Metal +Musical +National Folk +Native American +Neoclassical +Neue Deutsche Welle +New Age +New Romantic +New Wave +Noise +Nu-Breakz +Oldies +Opera +Podcast +Polka +Polsk Punk +Pop +Pop-Folk +Pop/Funk +Porn Groove +Post-Punk +Post-Rock +Power Ballad +Pranks +Primus +Progressive Rock +Psybient +Psychedelic +Psychedelic Rock +Psytrance +Punk +Punk Rock +Rap +Rave +Reggae +Retro +Revival +Rhythm and Blues +Rhythmic Soul +Rock +Rock & Roll +Salsa +Samba +Satire +Shoegaze +Showtunes +Ska +Slow Jam +Slow Rock +Sonata +Soul +Sound Clip +Soundtrack +Southern Rock +Space +Space Rock +Speech +Swing +Symphonic Rock +Symphony +Synthpop +Tango +Techno +Techno-Industrial +Terror +Thrash Metal +Top 40 +Trailer +Trance +Tribal +Trip-Hop +Trop Rock +Vocal +World Music \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..0baa0b6 --- /dev/null +++ b/models.py @@ -0,0 +1,257 @@ +""" +models.py +========= +SQLite database layer for the Theme Song Booth. + +This module defines the schema and all database operations. Flask's +application context (`g`) is used to manage one connection per request. + +Schema overview (see SCHEMA constant): +- requests table stores customer data, generated prompts, file paths, + approval state, email timestamps, payment reference, player token, + vocal gender preference, revision count, revision note, and operator notes. +- Revisions: when a customer requests changes, the current A/B MP3 files are + renamed to archived "RevN-" copies and new versions are uploaded later. +- operator_notes is an internal column for the booth team and is never shown + to customers. +- Indexes on status and player_token for fast queue/lookup. +""" + +import sqlite3 +import secrets +from datetime import datetime, timezone +from pathlib import Path +from flask import current_app, g + +# SQL executed by init_db() to create the requests table and indexes. +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, + pronouns TEXT, + extra_requests TEXT, + status TEXT DEFAULT 'pending', + suno_title TEXT, + suno_style TEXT, + suno_lyrics TEXT, + song_a_path TEXT, + song_b_path TEXT, + vocal_gender 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, -- reserved for future operator alerts; currently unused + player_token TEXT NOT NULL UNIQUE, + revision_count INTEGER DEFAULT 0, + revision_note TEXT, + operator_notes 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_token ON requests(player_token); + +CREATE TABLE IF NOT EXISTS revision_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + revision_count INTEGER NOT NULL, + note TEXT, + old_song_a_path TEXT, + old_song_b_path TEXT, + new_song_a_path TEXT, + new_song_b_path TEXT, + FOREIGN KEY (request_id) REFERENCES requests(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_revision_history_request ON revision_history(request_id); +""" + + +def get_db(): + """Get or create a SQLite connection tied to the current Flask request context.""" + 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): + """Close the request-scoped SQLite connection. Registered as teardown handler.""" + db = g.pop('db', None) + if db is not None: + db.close() + + +def init_db(): + """Create the database file and tables, adding any missing columns to existing tables.""" + db_path = current_app.config['DATABASE'] + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(db_path) + db.row_factory = sqlite3.Row + db.executescript(SCHEMA) + + # SQLite ALTER TABLE is limited; add newer columns if they are missing. + expected_columns = { + 'requests': [ + 'id', 'created_at', 'name', 'email', 'hobbies', 'notable_facts', + 'style_genre', 'pronouns', 'extra_requests', 'status', 'suno_title', 'suno_style', + 'suno_lyrics', 'song_a_path', 'song_b_path', 'vocal_gender', + 'customer_approved', 'approval_notified_at', 'preview_sent_at', + 'delivery_sent_at', 'square_payment_ref', 'admin_alert_email', + 'player_token', 'revision_count', 'revision_note', 'operator_notes', + 'stems_link', 'stems_interest' + ], + 'revision_history': [ + 'id', 'created_at', 'request_id', 'revision_count', 'note', + 'old_song_a_path', 'old_song_b_path', 'new_song_a_path', 'new_song_b_path' + ] + } + for table, columns in expected_columns.items(): + existing = {r['name'] for r in db.execute(f"PRAGMA table_info({table})")} + for col in columns: + if col not in existing: + # revision_count must be INTEGER so arithmetic in app.py works. + col_type = 'INTEGER' if col == 'revision_count' else 'TEXT' + db.execute(f'ALTER TABLE {table} ADD COLUMN {col} {col_type}') + db.commit() + db.close() + + +def new_token(): + """Generate a URL-safe random token used for private player links.""" + return secrets.token_urlsafe(32) + + +def now_utc(): + """Return current UTC time as ISO-8601 string for timestamp columns.""" + return datetime.now(timezone.utc).isoformat() + + +def create_request(name, email, hobbies, notable_facts, style_genre, extra_requests, vocal_gender=None, pronouns=None, stems_interest=0): + """ + Insert a new customer request. + Returns the auto-generated request id. + """ + db = get_db() + cur = db.execute( + """INSERT INTO requests + (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, player_token, stems_interest) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (name, email, hobbies, notable_facts, style_genre, pronouns, extra_requests, vocal_gender, new_token(), 1 if stems_interest else 0) + ) + db.commit() + return cur.lastrowid + + +def get_request_by_id(request_id): + """Fetch one request by numeric id. Returns dict or None.""" + 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): + """Fetch one request by its private player token. Returns dict or None.""" + 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): + """List all requests, optionally filtered by status, newest first. + Normalizes whitespace in the status parameter so URLs like "Needs Upload" + match the stored value.""" + db = get_db() + if status: + # Translate human filter names to stored status values. + status = status.lower().replace(' ', '_') + 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 get_requests_by_email(email): + """Fetch all requests for a given email address, newest first. + Email is compared case-insensitively and stripped of whitespace.""" + db = get_db() + rows = db.execute( + "SELECT * FROM requests WHERE LOWER(TRIM(email)) = LOWER(TRIM(?)) ORDER BY created_at DESC", + (email,) + ).fetchall() + return [dict(r) for r in rows] + + +def update_request(request_id, **fields): + """ + Update arbitrary columns for a request. + Example: update_request(1, status='prompt_ready', suno_style='...') + """ + 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() + + +def delete_request(request_id): + """Delete a single request row by id. Does NOT delete associated files + (the caller in app.py removes uploads before/after this call).""" + db = get_db() + db.execute('DELETE FROM requests WHERE id = ?', (request_id,)) + db.commit() + + +def log_revision(request_id, revision_count, note, old_a=None, old_b=None, new_a=None, new_b=None): + """Record a revision event in the revision_history table, creating it if it is missing.""" + db = get_db() + try: + db.execute( + """INSERT INTO revision_history + (request_id, revision_count, note, old_song_a_path, old_song_b_path, new_song_a_path, new_song_b_path) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (request_id, revision_count, note, old_a, old_b, new_a, new_b) + ) + db.commit() + except sqlite3.OperationalError as e: + if 'no such table' in str(e): + # Schema drift: table missing. Run init_db to add tables/columns, then retry once. + init_db() + db.execute( + """INSERT INTO revision_history + (request_id, revision_count, note, old_song_a_path, old_song_b_path, new_song_a_path, new_song_b_path) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (request_id, revision_count, note, old_a, old_b, new_a, new_b) + ) + db.commit() + else: + raise + + +def list_revision_history(request_id): + """Return all revision history rows for a request, oldest first.""" + db = get_db() + rows = db.execute( + 'SELECT * FROM revision_history WHERE request_id = ? ORDER BY created_at ASC', + (request_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def reset_all_requests(): + """Delete every row in the requests table and reset id auto-increment.""" + db = get_db() + db.execute('DELETE FROM requests') + db.execute('DELETE FROM sqlite_sequence WHERE name = ?', ('requests',)) + db.commit() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c5b0ac1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +# requirements.txt +# ================ +# +# Python dependencies for the Theme Song Booth Flask app. +# +# flask - web framework +# gunicorn - production WSGI server used by Dockerfile +# python-dotenv - loads .env files in development +# werkzeug - utilities for file uploads and secure filenames +# mutagen - MP3 metadata (ID3) tagging +# flask-limiter - public form rate limiting +# cryptography - encrypt stored SMTP password + +flask +gunicorn +python-dotenv +werkzeug +mutagen +flask-limiter +cryptography +requests diff --git a/static/Booth_closed.png b/static/Booth_closed.png new file mode 100755 index 0000000..f35c229 Binary files /dev/null and b/static/Booth_closed.png differ diff --git a/static/DM-Logo_email.png b/static/DM-Logo_email.png new file mode 100755 index 0000000..c5dc4ff Binary files /dev/null and b/static/DM-Logo_email.png differ diff --git a/static/Trollgorithm_booth.jpg b/static/Trollgorithm_booth.jpg new file mode 100644 index 0000000..56e0ddc Binary files /dev/null and b/static/Trollgorithm_booth.jpg differ diff --git a/static/qr-code.png b/static/qr-code.png new file mode 100755 index 0000000..75bca74 Binary files /dev/null and b/static/qr-code.png differ diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..c769d4a --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,183 @@ + + + + + + Admin Dashboard + {% if refresh_seconds and refresh_seconds > 0 %} + + {% endif %} + + + +
+ + + +

Theme Song Booth — Admin Dashboard

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + + +
+ All + Pending + Needs Upload + Awaiting Payment + Delivered + + {% if refresh_seconds and refresh_seconds > 0 %} + Auto-refresh every {{ refresh_seconds }}s + {% else %} + Auto-refresh off + {% endif %} + +
+ + + + + + + + + + + + + + + + {% for r in requests %} + + + + + + + + + + {% endfor %} + {% if not requests %} + + {% endif %} + +
IDNameEmailGenreStatusApprovedActions
#{{ r.id }}{{ r.name }}{{ r.email }}{{ r.style_genre or '-' }}{{ statuses[r.status] }}{% if r.customer_approved and r.customer_approved != 'none' %}{{ (r.customer_approved or 'none').upper() }}{% else %}-{% endif %} + Open +
+ +
+
No requests found.
+
+ + diff --git a/templates/admin/login.html b/templates/admin/login.html new file mode 100644 index 0000000..89a0eb2 --- /dev/null +++ b/templates/admin/login.html @@ -0,0 +1,70 @@ + + + + + + Admin Login + + + +
+

Booth Admin

+ + + + {% with messages = get_flashed_messages() %} + {% if messages %} +
{{ messages[0] }}
+ {% endif %} + {% endwith %} + + +
+ + diff --git a/templates/admin/pricing.html b/templates/admin/pricing.html new file mode 100644 index 0000000..97dba3b --- /dev/null +++ b/templates/admin/pricing.html @@ -0,0 +1,108 @@ + + + + + + Pricing — Admin + + + +
+ + +

Pricing

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+

Standard Items

+

Leave blank to hide a price. Enter numbers only or include currency symbols as you prefer.

+ + + + + + + + + + + + + +

Custom Items

+ {% for c in customs %} +
+
+ + +
+
+ + +
+
+ {% endfor %} + + +
+
+ + diff --git a/templates/admin/request.html b/templates/admin/request.html new file mode 100644 index 0000000..9c3cc79 --- /dev/null +++ b/templates/admin/request.html @@ -0,0 +1,550 @@ + + + + + + Request #{{ req.id }} — Admin + + + +
+

← Dashboard

+

Request #{{ req.id }} — {{ req.name }}

+
+

Status: {{ statuses[req.status] }}

+ {% if req.status != 'cancelled' %} +
+ + +
+ {% endif %} +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ +
+ +
+

Customer Info

+

Operators can correct customer details here. Click Save when done.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

Stored style: {{ req.style_genre or '—' }}

+ + + + + + + + + +
+ +
+
+ +

Request #: {{ req.id }}

+ + {% if req.revision_note %} +
+

📝 Revisions Requested{% if req.revision_count %}Revision #{{ req.revision_count }}{% endif %}

+

{{ req.revision_note }}

+ +
+ {% endif %} +
+ + +
+

Revision History

+ {% if revision_history %} +
    + {% for h in revision_history %} +
  • +
    Revision #{{ h.revision_count }} — {{ h.created_at }} + {% if h.old_song_a_path or h.old_song_b_path %} +
    Archived: + {% if h.old_song_a_path %}
    A: {{ basename(h.old_song_a_path) }}{% endif %} + {% if h.old_song_b_path %}
    B: {{ basename(h.old_song_b_path) }}{% endif %} + {% endif %} +
    +

    {{ h.note or 'No note provided.' }}

    +
  • + {% endfor %} +
+ {% else %} +

No revisions recorded yet.

+ {% endif %} +
+ + +
+

Operator Notes

+

Internal notes for the booth team. Customers cannot see this.

+
+ + + +
+ +
+
+
+ + +
+

Stems / Extras Link

+

Paste a self-hosted file share link (e.g. from Pingvin Share). It will appear on the customer player page once delivered.

+
+ + + +
+ +
+
+
+
+ + +
+ +
+

1. Generate & Save Suno Prompt

+ +

Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.

+ +
+ + + + + + + +
+ + + + +
+
+
+ + +
+

2. Upload Songs

+
+ +
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+ + + + + +
+ +
+
+
+ + +
+

3. Notify Customer

+

+ Preview email: + {% if req.preview_sent_at %} + ✅ Sent {{ req.preview_sent_at }} + {% else %} + ❌ Not sent yet + {% endif %} +

+

Both songs must be uploaded first.

+
+ + +
+ + {% if req.song_a_path and req.song_b_path %} +

+ Operator preview link: + Open customer player in new tab → +

+ {% endif %} +
+ + +
+

4. Payment & Delivery

+

+ Customer approved: + {% if req.customer_approved == 'none' %} + Nothing yet + {% else %} + {{ (req.customer_approved or 'none').upper() }} + {% endif %} +

+

+ Delivery email: + {% if req.delivery_sent_at %} + ✅ Sent {{ req.delivery_sent_at }} + {% else %} + ❌ Not sent yet + {% endif %} +

+ {% if req.square_payment_ref %} +

Square Payment Reference: {{ req.square_payment_ref }}

+ {% endif %} +
+ + + + +
+ +
+ +
+ +

Select files to deliver:

+ {% set all_files = [] %} + {% if req.song_a_path and file_exists(req.song_a_path) %} + {% set _ = all_files.append(req.song_a_path) %} + {% endif %} + {% if req.song_b_path and file_exists(req.song_b_path) %} + {% set _ = all_files.append(req.song_b_path) %} + {% endif %} + + {% set files_to_show = all_files + extra_files %} + {% for fpath in files_to_show %} +
+ + + {% if not (fpath == req.song_a_path or fpath == req.song_b_path) %} +
archived / revision file
+ {% endif %} +
+ {% else %} +

No files available. Upload songs first.

+ {% endfor %} + +
+ +
+ {% if req.customer_approved == 'none' %} +

⚠️ Customer must approve a version before you can mark paid or deliver.

+ {% endif %} +
+
+
+
+ + +
+ + diff --git a/templates/admin/sales.html b/templates/admin/sales.html new file mode 100644 index 0000000..5759f4c --- /dev/null +++ b/templates/admin/sales.html @@ -0,0 +1,72 @@ + + + + + + Sales Report — Admin + + + +
+ + +

Sales Report

+ + {% if sales %} + + + + + + + + + + + + {% for s in sales %} + + + + + + + + {% endfor %} + +
IDNameEmailPickedPayment Reference
#{{ s.id }}{{ s.name }}{{ s.email }}{% if s.customer_approved == 'both' %}Both Versions{% else %}Version {{ (s.customer_approved or 'none').upper() }}{% endif %}{{ s.square_payment_ref or '-' }}
+ {% else %} +

No delivered requests yet.

+ {% endif %} +
+ + diff --git a/templates/admin/settings.html b/templates/admin/settings.html new file mode 100644 index 0000000..7f94a79 --- /dev/null +++ b/templates/admin/settings.html @@ -0,0 +1,470 @@ + + + + + + Admin Settings + + + +
+
+

Admin Settings v{{ version }}

+ +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ + +
+

Booth Status

+

When closed, the public request page shows a "booth closed" message instead of the form.

+
+ + + + +
+
+ + +
+

Database Health

+ {% if not health.ok %} +

❌ {{ health.message }}

+ {% if health.missing_columns %} +

Missing columns: {{ health.missing_columns | join(', ') }}

+ {% endif %} + {% if health.missing_tables %} +

Missing tables: {{ health.missing_tables | join(', ') }}

+ {% endif %} +
+ + +
+ {% else %} +

✅ {{ health.message }}

+
+ + +
+ {% endif %} +
+ + +
+

Database Statistics

+

Total requests: {{ total_records }}

+

Database size: {{ db_size }}

+

Uploaded MP3s: {{ upload_file_count }} files in {{ upload_dir_count }} request folders

+

Uploads total size: {{ upload_size }}

+ {% if status_counts %} +

Status breakdown:

+
    + {% for status, count in status_counts.items() %} +
  • {{ status }}: {{ count }}
  • + {% endfor %} +
+ {% endif %} +

Database path:
{{ db_path }}

+

Uploads path:
{{ upload_path }}

+
+ + +
+

Revision Limit

+
+ + + +

Set to 0 to disable customer-submitted revisions. Operators can still upload new versions manually.

+ +
+
+ + +
+

Dashboard Auto-Refresh

+
+ + + + +
+
+ + +
+

Kiosk Display Mode

+

Set how the public /kiosk page cycles. Use -1 for QR only, 0 for pricing only, 1 for queue only, or 5+ seconds to cycle between QR, pricing, and queue.

+
+ + + +

Examples: -1 = QR only, 0 = pricing only, 1 = queue only, 10 = rotate QR → pricing → queue every 10 seconds.

+ +
+
+ + +
+

Hermes API Key

+

Set via Portainer env var HERMES_API_KEY. Used for callback auth.

+

Current key: {{ hermes_key_masked }}

+ + {% if not hermes_key_set %} +

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

+ {% endif %} + +
+ +

Callback Link Expiry

+

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

+
+ + + + +
+
+ + +
+

ntfy Notifications

+

Push notifications for new customer requests. Leave either field blank to disable.

+
+ + + + + + + + + + + + + + +
ntfy Server URL
ntfy Topic
ntfy Access Token + +

Required if the topic is access-controlled. Leave blank to keep the existing stored token.

+
+ +
+ +
+ +

Send Test Notification

+
+ + +
+
+ + +
+

Email (SMTP) Settings

+

These settings are used to send confirmation, preview, and delivery emails to customers. The password is stored encrypted using the Flask secret key.

+
+ + + + + + + + + + + + + + + + + + + + + + + +
SMTP Host
SMTP Port
SMTP Username
From Address
SMTP Password + +
+ +
+ +
+ +

Send Test Email

+
+ + + + +
+
+ + +
+

MP3 Metadata Tags

+

These values are written into every uploaded MP3 file. The song title from the prompt is written to the Title tag automatically. Blank fields are skipped.

+
+ + + + + + + + + + + + + + + + + + +
Artist
Album
Year
Comment
+ +
+
+ + +
+

Database Backup / Restore

+

Download a copy of the SQLite database before the event. Upload a previous backup to restore it; the current database will be renamed as a timestamped backup.

+ +
+ + +
+ +
+ + + + +
+
+ + +
+

Music Files Backup

+

Download all uploaded MP3 and archived revision files as a single ZIP.

+
+ + +
+
+ + +
+

⚠️ Reset System

+

Use this only at the start of a new event. It will delete every request and every uploaded MP3 file. This cannot be undone.

+
+ + +
+
+
+
+ + + + diff --git a/templates/closed.html b/templates/closed.html new file mode 100644 index 0000000..e9e43ac --- /dev/null +++ b/templates/closed.html @@ -0,0 +1,49 @@ + + + + + + Trollgorithm Theme Song Booth — Closed + + + +
+ +
+

Trollgorithm and the goblin engineers are on a break.

+

Please come back in a while and they should be back to work!

+
+
+ + diff --git a/templates/faq.html b/templates/faq.html new file mode 100644 index 0000000..8768f28 --- /dev/null +++ b/templates/faq.html @@ -0,0 +1,175 @@ + + + + + + FAQ — Trollgorithm Theme Song Booth + + + +
+
+

❓ Frequently Asked Questions

+ +

The basics

+

What is the Trollgorithm Song Booth?

+

We create a custom, AI-generated theme song just for you based on your personality, hobbies, and style preferences.

+ +

How long does it take to get my song?

+

Most songs are ready to preview within a few minutes to an hour, depending on how busy the booth is!

+ +

How much does it cost?

+

Pricing is displayed at the booth; you pay after listening to and choosing the version you want.

+ +

What do I actually receive?

+

You get an MP3 file of the version you selected, delivered straight to your email. WAV format is available for a slight additional charge.

+ +

How it works

+

What info do I need to give you?

+

Your name, email, hobbies, notable facts about you, a preferred style or genre, and any extra requests you want included.

+ +

Can I pick the style or genre?

+

Yes — tell us anything from “80s power ballad” to “cinematic orchestral” and we will aim for that vibe.

+ +

What if I want a specific singer voice?

+

You can select a vocal preference such as female, male, androgynous, or non-binary on the request form. We are unable to create specific singers due to copyright laws.

+ +

Can I request specific lyrics or topics?

+

Absolutely — put any specific lyrics, themes, or things to avoid in the “Extra requests” field.

+ +

What happens after I submit the form?

+

We generate a custom prompt, produce two versions of your song, and email you a private link to listen.

+ +

How do I hear my song when it's ready?

+

Click the private link in your email — it works on any phone, tablet, or computer.

+ +

Versions & revisions

+

What's the difference between Version A and Version B?

+

They are two different takes or arrangements of your theme song, so you can choose the one you like best.

+ +

Can I get both versions?

+

Yes — just select “I want both” on the preview page.

+ +

What if I don't like either version?

+

You can request a limited number of changes through the preview page, or speak to the booth operator.

+ +

Can I ask for changes? How many times?

+

Yes, a small number of revisions are allowed; the exact limit is shown on your preview page.

+ +

What if I change my mind after picking a version?

+

Let the booth operator know right away; if payment has not been finalized, we can usually fix it.

+ +

Limitations

+

What limitations are there for topics or lyrics?

+

Trollgorithm is a friendly band and will not produce lyrics that contain any of the following:

+
    +
  • Specific acts of violence, self-harm, terrorism, or serious crime against identifiable people.
  • +
  • Help create, or refine CSAM or sexual content involving minors.
  • +
  • Anything hate-crime related.
  • +
+ +

Can I use explicit lyrics?

+

Yes! Trollgorithm believes the occasional use of explicit or crude language is fine, but the booth operator is a stick in the mud and won't allow minors to make music filled with swear words!

+ +

Can you make a song using just lyrics I provide?

+

Yes! Trollgorithm loves to collaborate with song writers! Ask the booth operator for help!

+ +

What if I want to sing and they just play the music?

+

While Trollgorithm is talented, they are also still just Trolls. You would probably get eaten if you were in the recording booth with them.

+ +

What languages can Trollgorithm sing in?

+

Trollgorithm knows English, Spanish, and French best. They can attempt German, Italian, Portuguese, Japanese, Mandarin Chinese, Korean, Russian, Arabic, and Hindi — but remember, they're just trolls and goblins, not terribly smart. Have a fluent speaker listen and check the result to make sure it sounds right.

+ +

Payment & delivery

+

How do I pay?

+

We accept payment at the booth through Square, including card, tap, and cash where available.

+ +

Do you pay before or after hearing the song?

+

You hear the preview first, pick a version, and then pay before the final MP3 is emailed to you.

+ +

Where is my song delivered?

+

To the email address you gave us, so double-check it for typos before submitting.

+ +

What file format is it?

+

The delivered song is a standard MP3 file that plays on virtually any device. WAV files can be provided upon request. We can also get you STEMS. If you know, you know, otherwise these aren't for everybody.

+ +

Can you send it to someone else's email?

+

At the booth we can update the email address if needed, but the original request needs a valid email to start.

+ +

Ownership & usage

+

Do I own my theme song?

+

Once you have paid, you do! You can use it for whatever you want, commercially or privately. Trollgorithm does reserve the right to use any music it makes as part of its demonstration playlist.

+ +

Can I post it online?

+

Yes, for personal, or commercial use — feel free to share it on social media.

+ +

Is this AI-generated music? Is that legal?

+

Trollgorithm is a privately built AI music Writer, Producer, Sound Engineer, and Band. It does each step (with human input, of course!) and produces finished songs. Since the music and lyrics are generated from scratch, no copyright rules are broken.

+ +

Privacy & support

+

Is my personal information kept private?

+

Yes, your info is only used to create and deliver your song and is not shared or sold. Your information is only stored for a few days, to ensure you have received the end product you want. After that it is deleted.

+ +

What if I made a typo in my email?

+

Tell the booth operator; they can edit your email address so your song reaches you.

+ +

What if I don't get the email?

+

Check your spam or junk folder first, then ask the operator to resend or verify your address.

+ +

What if there's a technical problem?

+

Let the booth operator know and we will do our best to fix it or retry delivery.

+ +

Who do I talk to for help?

+

The booth operator is your best point of contact for any questions or issues.

+ + ← Back to the request form +
+
+ + diff --git a/templates/kiosk.html b/templates/kiosk.html new file mode 100644 index 0000000..45dfeba --- /dev/null +++ b/templates/kiosk.html @@ -0,0 +1,322 @@ + + + + + + Trollgorithm Booth — Kiosk + {% if refresh_seconds and refresh_seconds > 0 %} + + {% endif %} + + + +
+
+ {% if booth_open %} + +
Booth is Open ✅
+ {% else %} + +
Booth is Closed ❌
+ {% endif %} +
+ +
+ {% if mode == 'qr' %} +
+

Scan to Order

+ Scan to order +

Point your camera at the code to start your request.

+
+ {% elif mode == 'prices' %} +
+
+

Pricing

+ {% if price_items %} +
    + {% for item in price_items %} +
  • + {{ item.label }} + {{ item.price }} +
  • + {% endfor %} +
+ {% else %} +

Pricing coming soon. Ask the booth operator!

+ {% endif %} +
+
+ {% elif mode == 'queue' %} +
+
+

Live Queue

+ {% if queue %} +
    + {% for item in queue %} +
  • + #{{ item.id }} {{ item.name }} + {{ item.status }} +
  • + {% endfor %} +
+

If your request isn't shown, visit the request page and use the status link to check your place.

+ {% else %} +

No active requests right now. Be the first!

+ {% endif %} +
+
+ {% else %} +
+

Scan to Order

+ Scan to order +

Point your camera at the code to start your request.

+
+ + + {% endif %} +
+ + {% if mode == 'cycle' %} + + + {% endif %} +
+ + diff --git a/templates/player.html b/templates/player.html new file mode 100644 index 0000000..78e1c2a --- /dev/null +++ b/templates/player.html @@ -0,0 +1,207 @@ + + + + + + Your Theme Song + + + +
+

🎧 Your Custom Theme Song

+

Hi {{ req.name }}! Listen to both versions and pick the one you want.

+ + {% if req.suno_lyrics %} +
+

🎵 Song Lyrics

+ {% for line in req.suno_lyrics.splitlines() %} + {% set line = line.strip() %} + {% if line and not (line.startswith('[') and line.endswith(']')) %} + {{ line }} + {% elif not line %} +
+ {% endif %} + {% endfor %} +
+ {% endif %} + + +
+

Version A

+ +
+ + +
+

Version B

+ +
+ + + + {% if req.status == 'revisions_requested' %} + +
+

📝 Revision Requested

+

You asked for changes. We will generate a new version and update this page.

+

Your note: {{ req.revision_note }}

+
+ + {% elif req.status in ['awaiting_payment','paid','delivered'] %} + +
+

✅ Choice Received

+

You selected: {% if req.customer_approved == 'both' %}Both Versions{% else %}Version {{ (req.customer_approved or 'none').upper() }}{% endif %}

+ {% if req.status == 'awaiting_payment' %} +

Please return to the booth to finalize payment and collect your files.

+ {% elif req.status == 'paid' %} +

Payment recorded. Your files are being prepared.

+ {% elif req.status == 'delivered' %} +

Delivered! Check your email for the MP3 attachment(s).

+ {% if req.stems_link %} +

+ Download stems / extras → +

+

This link expires 3 months after delivery. Download soon.

+ {% endif %} + {% endif %} +
+ + {% else %} + +
+ +
+ + + +
+
+ + {% if revisions_left > 0 %} + +
+

Revisions remaining: {{ revisions_left }}

+ + +
+ +
+
+ {% else %} +

No revisions remaining. Please speak to the booth operator if you need further changes.

+ {% endif %} + {% endif %} + +
+ + diff --git a/templates/request.html b/templates/request.html new file mode 100644 index 0000000..2e6e78a --- /dev/null +++ b/templates/request.html @@ -0,0 +1,224 @@ + + + + + + Request Your Theme Song + + + +
+ + + +
+

🎵 Get Your Custom Theme Song 🎶

+

Tell us about yourself and we'll craft a one-of-a-kind song just for you.

+

Questions? Read our FAQ → · Already ordered? Check status →

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + + + + + + + + + + + + + + + + + + + + + + + +

Selected style: {% if form %}{{ form.decade or '' }}{% if form.decade and form.basic_style %}, {% endif %}{{ form.basic_style or '' }}{% if form.basic_style and form.additional_style %}, {% endif %}{{ form.additional_style or '' }}{% else %}—{% endif %}

+ + + + + + + + + + + + +
+

Your info is only used to create and deliver your song.

+
+
+ + diff --git a/templates/status.html b/templates/status.html new file mode 100644 index 0000000..0022f67 --- /dev/null +++ b/templates/status.html @@ -0,0 +1,191 @@ + + + + + + Check Your Order Status — Trollgorithm Theme Song Booth + + + +
+ + +
+

🔍 Check Your Order Status

+

Enter the email address you used when you requested your song.

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + +
+ + + +
+ + {% if searched %} +
+ {% if requests %} + {% for req in requests %} +
+

Request #{{ req.id }} — {{ req.name }}

+

Status: {{ statuses[req.status] }}

+ + {% if req.customer_approved and req.customer_approved != 'none' %} +

You selected: {% if req.customer_approved == 'both' %}Both Versions{% else %}Version {{ (req.customer_approved or 'none').upper() }}{% endif %}

+ {% endif %} + + {% if req.song_a_path and req.song_b_path %} +

Your preview link:

+ 🎧 Listen & Pick Your Version + {% endif %} +
+ {% endfor %} + {% else %} +

No requests found for that email address. Make sure you used the same email you gave us at the booth.

+ {% endif %} +
+ {% endif %} + + ← Back to the request form +
+
+ + diff --git a/templates/thanks.html b/templates/thanks.html new file mode 100644 index 0000000..aa9dbf7 --- /dev/null +++ b/templates/thanks.html @@ -0,0 +1,44 @@ + + + + + + Request Received + + + +
+

✅ Request Received

+

Thanks, {{ req.name }}! We'll craft your song and email you a link when it's ready.

+

Your request number: #{{ req.id }}

+

Bring this number to the booth if you want to check on progress.

+
+ +