Compare commits
No commits in common. "2f5050b29ff98ffa5c661ddb38dfe4f1845bbee6" and "24838c73ac58d20496245410e66870c67f9ccf98" have entirely different histories.
2f5050b29f
...
24838c73ac
10 changed files with 250 additions and 707 deletions
89
README.md
89
README.md
|
|
@ -1,10 +1,10 @@
|
||||||
# Theme Song Booth (MusicGPT Edition)
|
# Theme Song Booth (MusicGPT Edition)
|
||||||
|
|
||||||

|
**Version:** `v0.7.0`
|
||||||
|
|
||||||
A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate prompts via Hermes, queue generations through the MusicGPT API, and deliver final songs by email.
|
A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate prompts via Hermes, queue generations through the MusicGPT API, and deliver final songs by email.
|
||||||
|
|
||||||
The MusicGPT fork automates song generation: instead of manually creating songs in Suno and uploading MP3s, the operator saves a title/style/lyrics prompt and clicks **Generate A/B with MusicGPT**. The API returns two MP3 versions (plus WAVs and an album cover) and posts status updates to a webhook. Stems are extracted on demand and uploaded to a Gokapi file-sharing server for expiring download links.
|
The MusicGPT fork automates song generation: instead of manually creating songs in Suno and uploading MP3s, the operator saves a title/style/lyrics prompt and clicks **Generate A/B with MusicGPT**. The API returns two MP3 versions (plus WAVs and an album cover) and posts status updates to a webhook.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -15,14 +15,13 @@ The MusicGPT fork automates song generation: instead of manually creating songs
|
||||||
3. [Operator / admin pages](#operator--admin-pages)
|
3. [Operator / admin pages](#operator--admin-pages)
|
||||||
4. [Status flow](#status-flow)
|
4. [Status flow](#status-flow)
|
||||||
5. [MusicGPT workflow](#musicgpt-workflow)
|
5. [MusicGPT workflow](#musicgpt-workflow)
|
||||||
6. [Stems extraction](#stems-extraction)
|
6. [Settings page explained](#settings-page-explained)
|
||||||
7. [Settings page explained](#settings-page-explained)
|
7. [Docker installation](#docker-installation)
|
||||||
8. [Docker installation](#docker-installation)
|
8. [Environment variables](#environment-variables)
|
||||||
9. [Environment variables](#environment-variables)
|
9. [File layout](#file-layout)
|
||||||
10. [File layout](#file-layout)
|
10. [Local development](#local-development)
|
||||||
11. [Local development](#local-development)
|
11. [Common troubleshooting](#common-troubleshooting)
|
||||||
12. [Common troubleshooting](#common-troubleshooting)
|
12. [License / ownership](#license--ownership)
|
||||||
13. [License / ownership](#license--ownership)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -34,7 +33,7 @@ The MusicGPT fork automates song generation: instead of manually creating songs
|
||||||
4. The operator clicks **Generate A/B with MusicGPT**, selects a model, and waits for MusicGPT to finish. MusicGPT produces two MP3 versions, optional WAVs, and an album cover.
|
4. The operator clicks **Generate A/B with MusicGPT**, selects a model, and waits for MusicGPT to finish. MusicGPT produces two MP3 versions, optional WAVs, and an album cover.
|
||||||
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.
|
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) — optionally with WAV files and the album cover — by email.
|
6. After the customer approves and pays, the operator records the payment reference and delivers the selected MP3(s) — optionally with WAV files and the album cover — by email.
|
||||||
7. Optional STEMS can be generated for an extra fee. The operator selects which MP3 version to extract stems from via radio buttons, and the completed stems are automatically zipped and uploaded to Gokapi as a single expiring download link.
|
7. Optional STEMS can be generated for an extra fee and delivered via a share link.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -125,7 +124,7 @@ The app queues two conversions (Version A and Version B) with MusicGPT. It store
|
||||||
MusicGPT POSTs to `/api/musicgpt/webhook` when each conversion completes. The handler:
|
MusicGPT POSTs to `/api/musicgpt/webhook` when each conversion completes. The handler:
|
||||||
|
|
||||||
- Matches the payload to the request by `task_id` and `conversion_id`.
|
- Matches the payload to the request by `task_id` and `conversion_id`.
|
||||||
- Updates the request status and cost. Per-version costs are accumulated into a total (`musicgpt_cost`), with per-version breakdowns stored in `musicgpt_cost_a` and `musicgpt_cost_b`.
|
- Updates the request status and cost.
|
||||||
- Downloads album cover, MP3s, and WAVs when the payload includes URLs.
|
- Downloads album cover, MP3s, and WAVs when the payload includes URLs.
|
||||||
- Webhooks often arrive before audio URLs are ready; missing files are filled in by polling.
|
- Webhooks often arrive before audio URLs are ready; missing files are filled in by polling.
|
||||||
|
|
||||||
|
|
@ -143,13 +142,7 @@ All polling paths call `musicgpt_poll_status()` and run `download_musicgpt_outpu
|
||||||
|
|
||||||
### Cost tracking
|
### Cost tracking
|
||||||
|
|
||||||
The per-request **LLM Cost** display on `/admin/request/<id>` shows the combined total of song generation costs and stems extraction costs, with a breakdown in smaller text:
|
The `/admin/pricing` page shows aggregate MusicGPT costs in USD. The per-request cost is recorded from the webhook/poll payload (`conversion_cost`) when MusicGPT provides it; otherwise the queued estimate is shown. Stems are tracked separately by `stems_cost`.
|
||||||
|
|
||||||
```
|
|
||||||
LLM Cost: $0.2740 USD (songs: $0.1370 USD + stems: $0.1370 USD)
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-version costs are accumulated from separate webhook payloads rather than overwritten, so the total reflects both Version A and Version B. The `/admin/pricing` page shows aggregate MusicGPT costs across all requests in USD.
|
|
||||||
|
|
||||||
### WAV delivery
|
### WAV delivery
|
||||||
|
|
||||||
|
|
@ -161,42 +154,6 @@ A global toggle in `/admin/settings` controls whether the generated album cover
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Stems extraction
|
|
||||||
|
|
||||||
### Generating stems
|
|
||||||
|
|
||||||
1. On `/admin/request/<id>`, scroll to the **Stems** section in the Songs panel.
|
|
||||||
2. Select the source MP3 using the **radio buttons** — Version A or Version B. Only versions with files ready are selectable; unavailable versions are greyed out.
|
|
||||||
3. Click **Generate Stems**.
|
|
||||||
|
|
||||||
The app uploads the selected MP3 file directly to the MusicGPT Extraction API (the API's `audio_url` field only accepts YouTube URLs, so we use the `audio_file` upload option). The Extraction API separates vocals and instrumental tracks and posts the result to the webhook.
|
|
||||||
|
|
||||||
### Stems webhook and Gokapi upload
|
|
||||||
|
|
||||||
When the Extraction webhook fires with `COMPLETED` status:
|
|
||||||
|
|
||||||
1. The app parses the individual stem URLs (vocals, instrumental) from the webhook payload.
|
|
||||||
2. Downloads each stem file from the MusicGPT CDN.
|
|
||||||
3. Bundles them into a single zip archive named `stems_<song title>.zip`.
|
|
||||||
4. Uploads the zip to Gokapi (`GOKAPI_URL`) with a configurable expiry (default 30 days).
|
|
||||||
5. Saves the Gokapi download URL to `stems_link` in the database.
|
|
||||||
|
|
||||||
The **Download Stems** button on the admin request page links directly to the Gokapi download page. The `stems_link` is also included in the delivery email when stems are part of the order.
|
|
||||||
|
|
||||||
### Error recovery
|
|
||||||
|
|
||||||
If stems extraction fails, the error message is displayed on the admin request page but is automatically cleared on the next retry attempt. The **Generate Stems** button remains enabled when the status is `ERROR`, so the operator can retry without navigating away.
|
|
||||||
|
|
||||||
### Re-uploading existing stems
|
|
||||||
|
|
||||||
If the webhook completed before Gokapi was configured (or the Gokapi upload failed), a **Re-upload to Gokapi** button appears in the Stems / Extras Link section. This manually triggers the download-zip-upload flow for the existing stem files.
|
|
||||||
|
|
||||||
### Manual download fallback
|
|
||||||
|
|
||||||
If `stems_link` is empty but `stems_url` contains raw CDN URLs, the Download Stems button falls back to the `/admin/request/<id>/download-stems` endpoint, which downloads the stem files on the fly, zips them, and serves the zip as a browser download.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings page explained
|
## Settings page explained
|
||||||
|
|
||||||
The `/admin/settings` page is split into functional sections:
|
The `/admin/settings` page is split into functional sections:
|
||||||
|
|
@ -274,7 +231,7 @@ Use the output for `APP_SECRET_KEY`.
|
||||||
python init_db.py
|
python init_db.py
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Point your reverse proxy at the host port you chose (default `0.0.0.0:8500`).
|
7. Point your reverse proxy at the host port you chose (default `127.0.0.1:8000`).
|
||||||
8. Visit `/admin/settings`, confirm the MusicGPT webhook URL, and configure the MusicGPT account webhook endpoint to the same URL.
|
8. Visit `/admin/settings`, confirm the MusicGPT webhook URL, and configure the MusicGPT account webhook endpoint to the same URL.
|
||||||
9. Copy the Hermes API key to your Hermes skill / AI assistant.
|
9. Copy the Hermes API key to your Hermes skill / AI assistant.
|
||||||
10. Print or display a QR code pointing to `https://your-domain/request`.
|
10. Print or display a QR code pointing to `https://your-domain/request`.
|
||||||
|
|
@ -299,21 +256,18 @@ Persistent volumes keep the database and uploads safe across redeploys.
|
||||||
| `ADMIN_PASSWORD` | Yes | — | Password used to log in to `/admin`. |
|
| `ADMIN_PASSWORD` | Yes | — | Password used to log in to `/admin`. |
|
||||||
| `SMTP_PASS` | Yes | — | Password for the SMTP account used to send customer emails. |
|
| `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://music.dionysismedia.ca`. Used in player links, emails, callback URLs, and the MusicGPT webhook URL. |
|
| `PUBLIC_BASE_URL` | Yes | — | Public HTTPS URL of the booth, e.g. `https://music.dionysismedia.ca`. Used in player links, emails, callback URLs, and the MusicGPT webhook URL. |
|
||||||
| `MUSICGPT_API_KEY` | Yes | — | API key for MusicGPT generation, polling, and stems extraction. |
|
|
||||||
| `GOKAPI_URL` | No | — | Base URL of the Gokapi file-sharing server, e.g. `https://files.dionysismedia.ca`. Required for automatic stems upload. |
|
|
||||||
| `GOKAPI_API_KEY` | No | — | API key for the Gokapi REST API. Required for automatic stems upload. |
|
|
||||||
| `GOKAPI_EXPIRY_DAYS` | No | `30` | Number of days before uploaded stems files expire on Gokapi. |
|
|
||||||
| `SMTP_HOST` | No | `mailroot8.namespro.ca` | SMTP server hostname. |
|
| `SMTP_HOST` | No | `mailroot8.namespro.ca` | SMTP server hostname. |
|
||||||
| `SMTP_PORT` | No | `465` | SMTP server port. |
|
| `SMTP_PORT` | No | `465` | SMTP server port. |
|
||||||
| `SMTP_USER` | No | `ai@hallsworth.ca` | SMTP username. |
|
| `SMTP_USER` | No | `ai@hallsworth.ca` | SMTP username. |
|
||||||
| `SMTP_FROM` | No | `ai@hallsworth.ca` | From address for customer emails. |
|
| `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. |
|
| `BOOTH_NAME` | No | `Trollgorithm Theme Songs` | Display name used in email subjects and page titles. |
|
||||||
| `HOST_PORT` | No | `0.0.0.0:8500` | Host-side `ip:port` mapping for the container. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `CURRENCY` | No | `CAD` | Currency label shown with prices. |
|
||||||
| `MAX_REVISIONS` | No | `2` | Default customer revision limit before an operator override. |
|
| `MAX_REVISIONS` | No | `2` | Default customer revision limit before an operator override. |
|
||||||
| `HERMES_API_KEY` | No | — | API key for the `/api/prompt` callback. |
|
| `HERMES_API_KEY` | No | — | API key for the `/api/prompt` callback. |
|
||||||
|
| `MUSICGPT_API_KEY` | Yes | — | API key for MusicGPT generation and status polling. |
|
||||||
| `DATABASE` | No | `/app/data/booth.db` | Path to the SQLite database inside the container. |
|
| `DATABASE` | No | `/app/data/booth.db` | Path to the SQLite database inside the container. |
|
||||||
| `UPLOAD_FOLDER` | No | `/app/uploads` | Path to uploaded/downloaded song storage inside the container. |
|
| `UPLOAD_FOLDER` | No | `/app/uploads` | Path to uploaded/downloaded song storage inside the container. |
|
||||||
|
|
||||||
|
|
@ -323,10 +277,10 @@ Persistent volumes keep the database and uploads safe across redeploys.
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `app.py` | Flask routes, runtime settings, email layer, MP3 tagging, rate limiting, database maintenance, Hermes callback, kiosk, pricing, sales report, MusicGPT webhooks, polling, file downloads, stems download-zip endpoint, audio-source endpoint, and Gokapi upload trigger. |
|
| `app.py` | Flask routes, runtime settings, email layer, MP3 tagging, rate limiting, database maintenance, Hermes callback, kiosk, pricing, sales report, MusicGPT webhooks, polling, and file downloads. |
|
||||||
| `config.py` | Environment-variable based configuration with sensible defaults. Includes MusicGPT and Gokapi settings. |
|
| `config.py` | Environment-variable based configuration with sensible defaults. |
|
||||||
| `helpers.py` | MusicGPT API client, polling, downloads, email helpers, MP3 metadata, settings persistence, Gokapi upload, stems-to-Gokapi processing, cost formatting, and utility functions. |
|
| `helpers.py` | MusicGPT API client, polling, downloads, email helpers, MP3 metadata, settings persistence, and utility functions. |
|
||||||
| `models.py` | SQLite schema, CRUD helpers, and revision history. Includes per-version cost columns (`musicgpt_cost_a`, `musicgpt_cost_b`). |
|
| `models.py` | SQLite schema, CRUD helpers, and revision history. |
|
||||||
| `init_db.py` | Standalone script to create or migrate the database. |
|
| `init_db.py` | Standalone script to create or migrate the database. |
|
||||||
| `templates/` | Jinja2 templates for customer pages, admin pages, and kiosk display. |
|
| `templates/` | Jinja2 templates for customer pages, admin pages, and kiosk display. |
|
||||||
| `static/` | Banner images, closed banner, email logo, and kiosk QR code. |
|
| `static/` | Banner images, closed banner, email logo, and kiosk QR code. |
|
||||||
|
|
@ -335,7 +289,6 @@ Persistent volumes keep the database and uploads safe across redeploys.
|
||||||
| `docker-compose.yml` | Portainer stack definition. |
|
| `docker-compose.yml` | Portainer stack definition. |
|
||||||
| `requirements.txt` | Python dependencies. |
|
| `requirements.txt` | Python dependencies. |
|
||||||
| `.env.example` | Local development environment template. |
|
| `.env.example` | Local development environment template. |
|
||||||
| `VERSION` | Current version string (read by `config.py` and displayed on `/admin/settings`). |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -347,7 +300,6 @@ python3 -m venv .venv
|
||||||
.venv/bin/pip install -r requirements.txt
|
.venv/bin/pip install -r requirements.txt
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env and set APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL, MUSICGPT_API_KEY
|
# Edit .env and set APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL, MUSICGPT_API_KEY
|
||||||
# Optional: GOKAPI_URL, GOKAPI_API_KEY, GOKAPI_EXPIRY_DAYS
|
|
||||||
.venv/bin/python init_db.py
|
.venv/bin/python init_db.py
|
||||||
.venv/bin/python -m flask --app app run --host=0.0.0.0
|
.venv/bin/python -m flask --app app run --host=0.0.0.0
|
||||||
```
|
```
|
||||||
|
|
@ -372,9 +324,6 @@ Visit:
|
||||||
| `/api/prompt` returns 401 | Callback token expired or API key mismatch. | Copy a fresh callback URL from `/admin/request/<id>` and verify the key with `/api/key-test`. |
|
| `/api/prompt` returns 401 | Callback token expired or API key mismatch. | Copy a fresh callback URL from `/admin/request/<id>` and verify the key with `/api/key-test`. |
|
||||||
| MusicGPT generation fails | API key lacks access to the selected model. | Only `v6` and `v6-pro` are supported by the current key; choose from the dropdown. |
|
| MusicGPT generation fails | API key lacks access to the selected model. | Only `v6` and `v6-pro` are supported by the current key; choose from the dropdown. |
|
||||||
| Webhook arrives but no files download | MusicGPT webhook fires before audio URLs are ready. | Wait for the automatic background poll, click **Poll MusicGPT** on the request, or use **Refresh MusicGPT Status** on the dashboard. |
|
| Webhook arrives but no files download | MusicGPT webhook fires before audio URLs are ready. | Wait for the automatic background poll, click **Poll MusicGPT** on the request, or use **Refresh MusicGPT Status** on the dashboard. |
|
||||||
| Stems extraction returns HTTP 500 | Transient MusicGPT API error. | Retry by clicking **Generate Stems** again. The error message is cleared automatically on the next attempt. |
|
|
||||||
| Stems completed but no Gokapi link | Webhook fired before Gokapi was configured, or Gokapi upload failed. | Click **Re-upload to Gokapi** in the Stems / Extras Link section. |
|
|
||||||
| LLM Cost shows $0 for songs | Old webhook zeroed the cost estimate before the accumulation fix. | New requests will accumulate correctly. Historical data cannot be recovered. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.9.0
|
0.7.0
|
||||||
|
|
|
||||||
135
app.py
135
app.py
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
app.py
|
app.py
|
||||||
======
|
======
|
||||||
Main Flask application for the Theme Song Booth (MusicGPT Edition).
|
Main Flask application for the Theme Song Booth.
|
||||||
|
|
||||||
This module defines all HTTP routes, helper functions, the email layer,
|
This module defines all HTTP routes, helper functions, the email layer,
|
||||||
runtime settings persistence, MP3 metadata tagging, rate limiting, and
|
runtime settings persistence, MP3 metadata tagging, rate limiting, and
|
||||||
|
|
@ -16,9 +16,7 @@ Public routes (customers):
|
||||||
- /play/<token> -> private player page with Version A and B
|
- /play/<token> -> private player page with Version A and B
|
||||||
- /play/<token>/approve -> customer picks a version
|
- /play/<token>/approve -> customer picks a version
|
||||||
- /play/<token>/revise -> customer asks for changes
|
- /play/<token>/revise -> customer asks for changes
|
||||||
- /api/stream/<token>/<v>.mp3 -> streams uploaded MP3s (requires both A and B)
|
- /audio/<token>/<v>.mp3 -> serves the uploaded MP3 files
|
||||||
- /api/audio-source/<token>/<v>.mp3 -> serves a single MP3 for stems extraction API
|
|
||||||
- /audio/<token>/<v>.mp3 -> legacy, returns 404
|
|
||||||
|
|
||||||
Admin routes:
|
Admin routes:
|
||||||
- /admin/login -> password login
|
- /admin/login -> password login
|
||||||
|
|
@ -27,14 +25,7 @@ Admin routes:
|
||||||
- /admin/settings -> runtime settings, health check, DB stats, backup/restore, reset
|
- /admin/settings -> runtime settings, health check, DB stats, backup/restore, reset
|
||||||
- /admin/request/<id> -> detail/edit page for a single request
|
- /admin/request/<id> -> detail/edit page for a single request
|
||||||
- /admin/request/<id>/delete -> deletes one request and its uploaded files
|
- /admin/request/<id>/delete -> deletes one request and its uploaded files
|
||||||
- /admin/request/<id>/download-stems -> downloads all stems as a zip archive
|
|
||||||
- /admin/reset -> deletes ALL requests and ALL files
|
- /admin/reset -> deletes ALL requests and ALL files
|
||||||
|
|
||||||
API routes:
|
|
||||||
- /api/musicgpt/webhook -> MusicGPT async callback (generation + stems)
|
|
||||||
- /api/musicgpt/autopoll -> background poll for all in-flight tasks
|
|
||||||
- /api/prompt/<id> -> Hermes callback for generated prompts
|
|
||||||
- /api/key-test -> tests the Hermes API key
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Standard library imports
|
# Standard library imports
|
||||||
|
|
@ -75,9 +66,7 @@ from helpers import (
|
||||||
musicgpt_generate_request, musicgpt_queue_stems, musicgpt_poll_status,
|
musicgpt_generate_request, musicgpt_queue_stems, musicgpt_poll_status,
|
||||||
download_musicgpt_outputs, format_musicgpt_cost, get_musicgpt_cost_totals,
|
download_musicgpt_outputs, format_musicgpt_cost, get_musicgpt_cost_totals,
|
||||||
download_album_cover,
|
download_album_cover,
|
||||||
process_stems_to_gokapi,
|
|
||||||
get_ntfy_config, send_ntfy,
|
get_ntfy_config, send_ntfy,
|
||||||
get_hermes_webhook_config, set_hermes_webhook_config, send_hermes_webhook,
|
|
||||||
sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url,
|
sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url,
|
||||||
send_email, build_signature_images,
|
send_email, build_signature_images,
|
||||||
get_booth_open,
|
get_booth_open,
|
||||||
|
|
@ -92,25 +81,6 @@ from helpers import (
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config.from_object(Config)
|
app.config.from_object(Config)
|
||||||
|
|
||||||
# Template filter: format UTC ISO timestamp as "Tuesday, August 8, 2026 @ 9:24pm" in America/Regina.
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
_REGINA_TZ = ZoneInfo("America/Regina")
|
|
||||||
|
|
||||||
@app.template_filter("regina_dt")
|
|
||||||
def regina_dt_filter(value):
|
|
||||||
if not value:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
dt = datetime.fromisoformat(value)
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
|
||||||
dt = dt.astimezone(_REGINA_TZ)
|
|
||||||
return dt.strftime("%A, %B %-d, %Y @ %-I:%M%p").replace("AM", "am").replace("PM", "pm")
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return value
|
|
||||||
|
|
||||||
# Global request rate limiting by remote IP (default 60/min). The public form is further limited to 5/min.
|
# 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"])
|
limiter = Limiter(get_remote_address, app=app, default_limits=["60 per minute"])
|
||||||
|
|
||||||
|
|
@ -449,6 +419,7 @@ def musicgpt_webhook():
|
||||||
if not stems_url and audio_url_map:
|
if not stems_url and audio_url_map:
|
||||||
stems_url = '; '.join(f"{k}: {v}" for k, v in audio_url_map.items())
|
stems_url = '; '.join(f"{k}: {v}" for k, v in audio_url_map.items())
|
||||||
update_fields['stems_url'] = stems_url
|
update_fields['stems_url'] = stems_url
|
||||||
|
update_fields['stems_link'] = stems_url
|
||||||
try:
|
try:
|
||||||
sc = float(data.get('conversion_cost') or 0)
|
sc = float(data.get('conversion_cost') or 0)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -458,16 +429,6 @@ def musicgpt_webhook():
|
||||||
elif new_status in ('FAILED', 'ERROR'):
|
elif new_status in ('FAILED', 'ERROR'):
|
||||||
update_fields['stems_error'] = data.get('reason') or data.get('error') or 'Extraction failed'
|
update_fields['stems_error'] = data.get('reason') or data.get('error') or 'Extraction failed'
|
||||||
update_request(req['id'], **update_fields)
|
update_request(req['id'], **update_fields)
|
||||||
|
|
||||||
# After stems are complete, upload them to Gokapi for a shareable link.
|
|
||||||
if new_status in ('COMPLETED', 'FINISHED') and Config.GOKAPI_URL and Config.GOKAPI_API_KEY:
|
|
||||||
row = db.execute('SELECT * FROM requests WHERE id = ?', (req['id'],)).fetchone()
|
|
||||||
req = dict(row)
|
|
||||||
gokapi_url, gokapi_err = process_stems_to_gokapi(req)
|
|
||||||
if gokapi_err:
|
|
||||||
app.logger.warning(f'Gokapi upload failed for request {req["id"]}: {gokapi_err}')
|
|
||||||
# Note: process_stems_to_gokapi already saves stems_link to the DB on success.
|
|
||||||
|
|
||||||
return jsonify({'ok': True, 'request_id': req['id']}), 200
|
return jsonify({'ok': True, 'request_id': req['id']}), 200
|
||||||
|
|
||||||
return jsonify({'ok': False, 'reason': 'unknown_conversion_type'}), 400
|
return jsonify({'ok': False, 'reason': 'unknown_conversion_type'}), 400
|
||||||
|
|
@ -1174,18 +1135,6 @@ def admin_request(rid):
|
||||||
flash('Stems share link saved.', 'success')
|
flash('Stems share link saved.', 'success')
|
||||||
return redirect(url_for('admin_request', rid=rid))
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
elif action == 'reprocess_stems':
|
|
||||||
# Manually trigger Gokapi upload for existing stems (e.g. webhook fired before Gokapi was configured).
|
|
||||||
if not req.get('stems_url'):
|
|
||||||
flash('No stems files to process.', 'error')
|
|
||||||
return redirect(url_for('admin_request', rid=rid))
|
|
||||||
gokapi_url, gokapi_err = process_stems_to_gokapi(req)
|
|
||||||
if gokapi_err:
|
|
||||||
flash(f'Failed to upload stems to Gokapi: {gokapi_err}', 'error')
|
|
||||||
else:
|
|
||||||
flash(f'Stems uploaded to Gokapi: {gokapi_url}', 'success')
|
|
||||||
return redirect(url_for('admin_request', rid=rid))
|
|
||||||
|
|
||||||
elif action == 'cancel_request':
|
elif action == 'cancel_request':
|
||||||
update_request(rid, status='cancelled')
|
update_request(rid, status='cancelled')
|
||||||
flash('Request marked as cancelled.', 'success')
|
flash('Request marked as cancelled.', 'success')
|
||||||
|
|
@ -1265,7 +1214,6 @@ def admin_request(rid):
|
||||||
|
|
||||||
elif action == 'generate_stems':
|
elif action == 'generate_stems':
|
||||||
# Queue a stem extraction job for the selected MP3 (Version A or B).
|
# Queue a stem extraction job for the selected MP3 (Version A or B).
|
||||||
# Clear any previous error at the start of every attempt.
|
|
||||||
api_key = get_musicgpt_api_key()
|
api_key = get_musicgpt_api_key()
|
||||||
if not api_key:
|
if not api_key:
|
||||||
flash('MusicGPT API key is not configured.', 'error')
|
flash('MusicGPT API key is not configured.', 'error')
|
||||||
|
|
@ -1273,8 +1221,6 @@ def admin_request(rid):
|
||||||
if req.get('stems_status') in ('IN_QUEUE', 'IN_PROGRESS'):
|
if req.get('stems_status') in ('IN_QUEUE', 'IN_PROGRESS'):
|
||||||
flash('A stems extraction is already in progress.', 'error')
|
flash('A stems extraction is already in progress.', 'error')
|
||||||
return redirect(url_for('admin_request', rid=rid))
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
# Clear prior error so the operator doesn't see a stale message after retry.
|
|
||||||
update_request(rid, stems_error=None)
|
|
||||||
# Operator selects which version (A or B) to extract stems from.
|
# Operator selects which version (A or B) to extract stems from.
|
||||||
selected = request.form.get('stems_source', '').strip()
|
selected = request.form.get('stems_source', '').strip()
|
||||||
if selected == 'a':
|
if selected == 'a':
|
||||||
|
|
@ -1287,10 +1233,10 @@ def admin_request(rid):
|
||||||
if not song_path or not Path(song_path).exists():
|
if not song_path or not Path(song_path).exists():
|
||||||
flash(f'Version {selected.upper()} MP3 is not available. Generate or upload it first.', 'error')
|
flash(f'Version {selected.upper()} MP3 is not available. Generate or upload it first.', 'error')
|
||||||
return redirect(url_for('admin_request', rid=rid))
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
# Upload the MP3 file directly to the Extraction API.
|
# Build a public URL the MusicGPT Extraction API can fetch.
|
||||||
# The API's audio_url field only supports YouTube URLs, not direct MP3 links.
|
audio_url = f"{current_app.config['PUBLIC_BASE_URL'].rstrip('/')}/api/audio-source/{req['player_token']}/{selected}.mp3"
|
||||||
stems = request.form.getlist('stems') or ['vocals', 'instrumental']
|
stems = request.form.getlist('stems') or ['vocals', 'instrumental']
|
||||||
task_id, conv_id, estimate, error = musicgpt_queue_stems(rid, song_path, stems=stems)
|
task_id, conv_id, estimate, error = musicgpt_queue_stems(rid, audio_url, stems=stems)
|
||||||
if error:
|
if error:
|
||||||
update_request(rid, stems_status='ERROR', stems_error=error)
|
update_request(rid, stems_status='ERROR', stems_error=error)
|
||||||
flash(f'Failed to queue stems extraction: {error}', 'error')
|
flash(f'Failed to queue stems extraction: {error}', 'error')
|
||||||
|
|
@ -1504,38 +1450,6 @@ def admin_request(rid):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/request/<int:rid>/send-to-hermes', methods=['POST'])
|
|
||||||
def send_to_hermes(rid):
|
|
||||||
"""
|
|
||||||
Operator action: push the current request data to the configured Hermes webhook.
|
|
||||||
Hermes will generate a MusicGPT prompt and POST it back to the callback URL.
|
|
||||||
"""
|
|
||||||
redir = require_admin()
|
|
||||||
if redir:
|
|
||||||
return redir
|
|
||||||
req = get_request_by_id(rid)
|
|
||||||
if not req:
|
|
||||||
abort(404)
|
|
||||||
if req['status'] not in ('pending', 'revisions_requested'):
|
|
||||||
flash('Request must be pending or awaiting revision to send to Hermes.', 'error')
|
|
||||||
return redirect(url_for('admin_request', rid=rid))
|
|
||||||
|
|
||||||
cfg = get_hermes_webhook_config()
|
|
||||||
if not cfg['url'] or not cfg['secret']:
|
|
||||||
flash('Hermes webhook is not configured. Set URL and secret in /admin/settings.', 'error')
|
|
||||||
return redirect(url_for('admin_request', rid=rid))
|
|
||||||
|
|
||||||
callback_url = build_prompt_callback_url(rid)
|
|
||||||
ok, msg = send_hermes_webhook(rid, req, callback_url=callback_url)
|
|
||||||
if ok:
|
|
||||||
flash(f'Sent to Hermes. {msg}', 'success')
|
|
||||||
else:
|
|
||||||
flash(f'Failed to send to Hermes: {msg}', 'error')
|
|
||||||
return redirect(url_for('admin_request', rid=rid))
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])
|
@app.route('/admin/request/<int:rid>/delete', methods=['POST'])
|
||||||
def admin_delete_request(rid):
|
def admin_delete_request(rid):
|
||||||
"""Delete a single request and remove its uploaded MP3 files."""
|
"""Delete a single request and remove its uploaded MP3 files."""
|
||||||
|
|
@ -1883,42 +1797,6 @@ def admin_settings():
|
||||||
flash('ntfy notification settings saved.', 'success')
|
flash('ntfy notification settings saved.', 'success')
|
||||||
return redirect(url_for('admin_settings'))
|
return redirect(url_for('admin_settings'))
|
||||||
|
|
||||||
elif action == 'save_hermes_webhook':
|
|
||||||
# Update Hermes webhook URL and secret from the settings form.
|
|
||||||
webhook_url = request.form.get('hermes_webhook_url', '').strip().rstrip('/')
|
|
||||||
webhook_secret = request.form.get('hermes_webhook_secret', '').strip()
|
|
||||||
set_hermes_webhook_config(webhook_url, webhook_secret)
|
|
||||||
flash('Hermes webhook settings saved.', 'success')
|
|
||||||
return redirect(url_for('admin_settings'))
|
|
||||||
|
|
||||||
elif action == 'send_test_hermes_webhook':
|
|
||||||
# Send a test request to the configured Hermes webhook.
|
|
||||||
cfg = get_hermes_webhook_config()
|
|
||||||
if not cfg['url'] or not cfg['secret']:
|
|
||||||
flash('Configure Hermes webhook URL and secret first.', 'error')
|
|
||||||
return redirect(url_for('admin_settings'))
|
|
||||||
ok, msg = send_hermes_webhook(0, {
|
|
||||||
'email': 'test@example.com',
|
|
||||||
'name': 'Test Customer',
|
|
||||||
'pronouns': 'They/Them/Their',
|
|
||||||
'hobbies': 'testing',
|
|
||||||
'notable_facts': 'none',
|
|
||||||
'style_genre': "1980's, Pop, Funk",
|
|
||||||
'vocal_gender': 'female',
|
|
||||||
'extra_requests': 'Test webhook',
|
|
||||||
'stems_interest': 0,
|
|
||||||
'revision_count': 0,
|
|
||||||
'revision_note': '',
|
|
||||||
'suno_title': '',
|
|
||||||
'suno_style': '',
|
|
||||||
'suno_lyrics': '',
|
|
||||||
})
|
|
||||||
if ok:
|
|
||||||
flash(f'Test Hermes webhook sent. {msg}', 'success')
|
|
||||||
else:
|
|
||||||
flash(f'Test Hermes webhook failed: {msg}', 'error')
|
|
||||||
return redirect(url_for('admin_settings'))
|
|
||||||
|
|
||||||
elif action == 'send_test_ntfy':
|
elif action == 'send_test_ntfy':
|
||||||
# Send a test push notification to the configured ntfy topic.
|
# Send a test push notification to the configured ntfy topic.
|
||||||
ntfy = get_ntfy_config()
|
ntfy = get_ntfy_config()
|
||||||
|
|
@ -1980,7 +1858,6 @@ def admin_settings():
|
||||||
musicgpt_api_key_set=bool(get_musicgpt_api_key()),
|
musicgpt_api_key_set=bool(get_musicgpt_api_key()),
|
||||||
musicgpt_webhook_url=build_musicgpt_webhook_url(),
|
musicgpt_webhook_url=build_musicgpt_webhook_url(),
|
||||||
musicgpt_autopoll=runtime_settings.get('musicgpt_autopoll', True),
|
musicgpt_autopoll=runtime_settings.get('musicgpt_autopoll', True),
|
||||||
hermes_webhook=get_hermes_webhook_config(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,6 @@ Environment variables (defaults shown):
|
||||||
- HERMES_API_KEY API key for the /api/prompt callback (optional, can be set in settings)
|
- HERMES_API_KEY API key for the /api/prompt callback (optional, can be set in settings)
|
||||||
- MUSICGPT_DEFAULT_MODEL default model selected in the admin dropdown (default v6-pro)
|
- MUSICGPT_DEFAULT_MODEL default model selected in the admin dropdown (default v6-pro)
|
||||||
- MUSICGPT_MODELS comma-separated list of models available to the API key (default v6,v6-pro)
|
- MUSICGPT_MODELS comma-separated list of models available to the API key (default v6,v6-pro)
|
||||||
- GOKAPI_URL base URL of the Gokapi file-sharing server (optional, for stems upload)
|
|
||||||
- GOKAPI_API_KEY API key for the Gokapi REST API (optional)
|
|
||||||
- GOKAPI_EXPIRY_DAYS days before uploaded stems expire on Gokapi (default 30)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -113,11 +110,6 @@ class Config:
|
||||||
MUSICGPT_MODELS = ['v6', 'v6-pro']
|
MUSICGPT_MODELS = ['v6', 'v6-pro']
|
||||||
MUSICGPT_DEFAULT_MODEL = os.environ.get('MUSICGPT_DEFAULT_MODEL', 'v6-pro')
|
MUSICGPT_DEFAULT_MODEL = os.environ.get('MUSICGPT_DEFAULT_MODEL', 'v6-pro')
|
||||||
|
|
||||||
# Gokapi file-sharing server for stems uploads.
|
|
||||||
GOKAPI_URL = os.environ.get('GOKAPI_URL', '')
|
|
||||||
GOKAPI_API_KEY = os.environ.get('GOKAPI_API_KEY', '')
|
|
||||||
GOKAPI_EXPIRY_DAYS = int(os.environ.get('GOKAPI_EXPIRY_DAYS', '30'))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def musicgpt_webhook_base_url(cls):
|
def musicgpt_webhook_base_url(cls):
|
||||||
"""Return the public base URL used for MusicGPT webhooks."""
|
"""Return the public base URL used for MusicGPT webhooks."""
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,7 @@
|
||||||
# Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL, MUSICGPT_API_KEY
|
# Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL, MUSICGPT_API_KEY
|
||||||
# Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION,
|
# Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION,
|
||||||
# CURRENCY, MAX_REVISIONS, HERMES_API_KEY, DATABASE, UPLOAD_FOLDER,
|
# CURRENCY, MAX_REVISIONS, HERMES_API_KEY, DATABASE, UPLOAD_FOLDER,
|
||||||
# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM,
|
# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM
|
||||||
# GOKAPI_URL, GOKAPI_API_KEY, GOKAPI_EXPIRY_DAYS
|
|
||||||
#
|
#
|
||||||
# Named volumes keep the SQLite database and uploaded/downloaded songs persistent
|
# Named volumes keep the SQLite database and uploaded/downloaded songs persistent
|
||||||
# across container restarts and redeploys.
|
# across container restarts and redeploys.
|
||||||
|
|
@ -41,9 +40,6 @@ services:
|
||||||
- CURRENCY=${CURRENCY:-CAD}
|
- CURRENCY=${CURRENCY:-CAD}
|
||||||
- DATABASE=${DATABASE:-/app/data/booth.db}
|
- DATABASE=${DATABASE:-/app/data/booth.db}
|
||||||
- UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads}
|
- UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads}
|
||||||
- GOKAPI_URL=${GOKAPI_URL:-}
|
|
||||||
- GOKAPI_API_KEY=${GOKAPI_API_KEY:-}
|
|
||||||
- GOKAPI_EXPIRY_DAYS=${GOKAPI_EXPIRY_DAYS:-30}
|
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_PORT:-0.0.0.0:8500}:${INTERNAL_PORT:-8000}"
|
- "${HOST_PORT:-0.0.0.0:8500}:${INTERNAL_PORT:-8000}"
|
||||||
volumes:
|
volumes:
|
||||||
|
|
|
||||||
231
helpers.py
231
helpers.py
|
|
@ -5,15 +5,6 @@ Utility and configuration helpers for the Theme Song Booth Flask app.
|
||||||
|
|
||||||
These functions are stateless (or use Flask's current_app / session context)
|
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.
|
and are imported by app.py. Keeping them here reduces the size of the route file.
|
||||||
|
|
||||||
Key function groups:
|
|
||||||
- Genre/decade parsing and style formatting
|
|
||||||
- Admin auth, settings persistence, encryption helpers
|
|
||||||
- Email sending (SMTP) and ntfy notifications
|
|
||||||
- Hermes API key management and prompt callback signing
|
|
||||||
- MusicGPT API client: generation, polling, file downloads, cost tracking
|
|
||||||
- Gokapi integration: stems upload, zip bundling, share link generation
|
|
||||||
- MP3 metadata tagging (mutagen)
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -43,7 +34,6 @@ from mutagen.easyid3 import EasyID3
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from models import update_request
|
from models import update_request
|
||||||
from config import Config
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -409,97 +399,6 @@ def get_ntfy_config():
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_hermes_webhook_config():
|
|
||||||
"""Return the Hermes webhook URL and HMAC secret from runtime settings."""
|
|
||||||
cfg = load_booth_settings()
|
|
||||||
return {
|
|
||||||
'url': cfg.get('hermes_webhook_url', '').strip(),
|
|
||||||
'secret': decrypt_value(cfg.get('hermes_webhook_secret', '')) or '',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def set_hermes_webhook_config(url, secret):
|
|
||||||
"""Persist Hermes webhook URL and secret (encrypted) to runtime settings."""
|
|
||||||
cfg = load_booth_settings()
|
|
||||||
cfg['hermes_webhook_url'] = url.strip().rstrip('/')
|
|
||||||
if secret:
|
|
||||||
cfg['hermes_webhook_secret'] = encrypt_value(secret)
|
|
||||||
save_booth_settings(cfg)
|
|
||||||
|
|
||||||
|
|
||||||
def send_hermes_webhook(rid, req, callback_url=None):
|
|
||||||
"""
|
|
||||||
POST a song request payload to the configured Hermes webhook.
|
|
||||||
|
|
||||||
Returns (success: bool, message: str). On success, Hermes receives the
|
|
||||||
customer data and can generate a Suno/MusicGPT prompt. If callback_url is
|
|
||||||
provided, Hermes can POST the generated prompt directly back to
|
|
||||||
/api/prompt/<rid>.
|
|
||||||
"""
|
|
||||||
cfg = get_hermes_webhook_config()
|
|
||||||
url = cfg.get('url', '')
|
|
||||||
secret = cfg.get('secret', '')
|
|
||||||
if not url or not secret:
|
|
||||||
return False, 'Hermes webhook is not configured in /admin/settings'
|
|
||||||
|
|
||||||
style_genre = req.get('style_genre') or ''
|
|
||||||
style_parts = [p.strip() for p in style_genre.split(',') if p.strip()]
|
|
||||||
style_sentence = ''
|
|
||||||
if style_parts:
|
|
||||||
parts = []
|
|
||||||
if style_parts[0]:
|
|
||||||
parts.append(f"{style_parts[0]}-era")
|
|
||||||
if len(style_parts) > 1 and style_parts[1]:
|
|
||||||
parts.append(style_parts[1])
|
|
||||||
if len(style_parts) > 2:
|
|
||||||
parts.append(f"with {', '.join(style_parts[2:])} influences")
|
|
||||||
style_sentence = ' '.join(parts)
|
|
||||||
|
|
||||||
def _bool(value):
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
return bool(int(value or 0))
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
'event_type': 'song_request',
|
|
||||||
'request_id': rid,
|
|
||||||
'email': req.get('email', ''),
|
|
||||||
'name': req.get('name', ''),
|
|
||||||
'pronouns': req.get('pronouns', ''),
|
|
||||||
'hobbies': req.get('hobbies', ''),
|
|
||||||
'notable_facts': req.get('notable_facts', ''),
|
|
||||||
'style_genre': style_sentence or style_genre,
|
|
||||||
'vocal_gender': req.get('vocal_gender', ''),
|
|
||||||
'extra_requests': req.get('extra_requests', ''),
|
|
||||||
'stems_interest': _bool(req.get('stems_interest')),
|
|
||||||
'is_revision': bool(req.get('revision_count', 0)) and bool(req.get('revision_note')),
|
|
||||||
'revision_count': int(req.get('revision_count') or 0),
|
|
||||||
'revision_note': req.get('revision_note', ''),
|
|
||||||
'previous_title': req.get('suno_title', ''),
|
|
||||||
'previous_style': req.get('suno_style', ''),
|
|
||||||
'previous_lyrics': req.get('suno_lyrics', ''),
|
|
||||||
'callback_url': callback_url or '',
|
|
||||||
}
|
|
||||||
|
|
||||||
body = json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
|
||||||
timestamp = str(int(time.time()))
|
|
||||||
sig_data = f"{timestamp}.{body.decode('utf-8')}"
|
|
||||||
signature = hmac.new(secret.encode('utf-8'), sig_data.encode('utf-8'), hashlib.sha256).hexdigest()
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Webhook-Signature-V2': signature,
|
|
||||||
'X-Webhook-Timestamp': timestamp,
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
resp = requests.post(url, data=body, headers=headers, timeout=15)
|
|
||||||
if resp.status_code in (200, 202):
|
|
||||||
return True, f"Sent to Hermes (HTTP {resp.status_code})"
|
|
||||||
return False, f"Hermes webhook returned HTTP {resp.status_code}: {resp.text[:200]}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"Failed to reach Hermes webhook: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'):
|
def send_ntfy(message, title='Theme Song Booth', priority='default', tags='bell'):
|
||||||
"""Send a push notification to the configured ntfy topic, if configured."""
|
"""Send a push notification to the configured ntfy topic, if configured."""
|
||||||
ntfy = get_ntfy_config()
|
ntfy = get_ntfy_config()
|
||||||
|
|
@ -710,26 +609,21 @@ def musicgpt_poll_status(task_id):
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
def musicgpt_queue_stems(rid, audio_file_path, stems=None):
|
def musicgpt_queue_stems(rid, audio_url, stems=None):
|
||||||
"""
|
"""
|
||||||
Queue a stem extraction job by uploading the MP3 file directly.
|
Queue a stem extraction job for a generated audio URL.
|
||||||
|
|
||||||
The MusicGPT Extraction API's audio_url field only accepts YouTube URLs,
|
|
||||||
not direct MP3 URLs. We use the audio_file upload option instead.
|
|
||||||
|
|
||||||
Returns (task_id, conversion_id, credit_estimate, error_message).
|
Returns (task_id, conversion_id, credit_estimate, error_message).
|
||||||
"""
|
"""
|
||||||
url = f"{MUSICGPT_API_BASE}/v2/Extraction"
|
url = f"{MUSICGPT_API_BASE}/v2/Extraction"
|
||||||
if stems is None:
|
if stems is None:
|
||||||
stems = ["vocals", "instrumental"]
|
stems = ["vocals", "instrumental"]
|
||||||
payload = {
|
payload = {
|
||||||
|
"audio_url": audio_url,
|
||||||
"stems": json.dumps(stems),
|
"stems": json.dumps(stems),
|
||||||
"webhook_url": build_musicgpt_webhook_url(),
|
"webhook_url": build_musicgpt_webhook_url(),
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
with open(audio_file_path, "rb") as f:
|
resp = requests.post(url, data=payload, headers={"Authorization": get_musicgpt_api_key()}, timeout=30)
|
||||||
files = {"audio_file": f}
|
|
||||||
resp = requests.post(url, data=payload, files=files, headers={"Authorization": get_musicgpt_api_key()}, timeout=60)
|
|
||||||
try:
|
try:
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -900,120 +794,3 @@ def get_musicgpt_cost_totals():
|
||||||
"total_cost": rows["total_cost"] or 0,
|
"total_cost": rows["total_cost"] or 0,
|
||||||
"stems_total": rows["stems_total"] or 0,
|
"stems_total": rows["stems_total"] or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Gokapi file-sharing integration (stems upload)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def parse_stems_urls(stems_url_field):
|
|
||||||
"""
|
|
||||||
Parse the stems_url DB field into a list of (label, url) tuples.
|
|
||||||
The field can be:
|
|
||||||
- "label1: url1; label2: url2" (multiple individual stem files)
|
|
||||||
- A single bundle/zip URL
|
|
||||||
- A bare URL
|
|
||||||
"""
|
|
||||||
if not stems_url_field:
|
|
||||||
return []
|
|
||||||
urls = []
|
|
||||||
if ';' in stems_url_field or ': ' in stems_url_field:
|
|
||||||
parts = stems_url_field.split(';')
|
|
||||||
for part in parts:
|
|
||||||
part = part.strip()
|
|
||||||
if not part:
|
|
||||||
continue
|
|
||||||
if ': ' in part:
|
|
||||||
label, url = part.split(': ', 1)
|
|
||||||
urls.append((label.strip(), url.strip()))
|
|
||||||
else:
|
|
||||||
urls.append(('stem', part))
|
|
||||||
else:
|
|
||||||
urls.append(('stems', stems_url_field.strip()))
|
|
||||||
return urls
|
|
||||||
|
|
||||||
|
|
||||||
def upload_to_gokapi(file_bytes, filename, expiry_days=30):
|
|
||||||
"""
|
|
||||||
Upload a file to Gokapi and return the download URL.
|
|
||||||
Returns (download_url, error_message).
|
|
||||||
"""
|
|
||||||
gokapi_url = Config.GOKAPI_URL.rstrip('/')
|
|
||||||
api_key = Config.GOKAPI_API_KEY
|
|
||||||
if not gokapi_url or not api_key:
|
|
||||||
return None, 'Gokapi URL or API key not configured'
|
|
||||||
try:
|
|
||||||
resp = requests.post(
|
|
||||||
f"{gokapi_url}/api/files/add",
|
|
||||||
headers={"apikey": api_key},
|
|
||||||
files={"file": (filename, file_bytes, "application/octet-stream")},
|
|
||||||
data={
|
|
||||||
"allowedDownloads": "0",
|
|
||||||
"expiryDays": str(expiry_days),
|
|
||||||
"password": "",
|
|
||||||
},
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return None, f'Gokapi HTTP {resp.status_code}: {resp.text[:200]}'
|
|
||||||
data = resp.json()
|
|
||||||
if data.get('Result') != 'OK':
|
|
||||||
return None, f"Gokapi error: {data.get('Result')}"
|
|
||||||
file_info = data.get('FileInfo', {})
|
|
||||||
download_url = file_info.get('UrlDownload')
|
|
||||||
if not download_url:
|
|
||||||
return None, 'Gokapi returned no download URL'
|
|
||||||
return download_url, None
|
|
||||||
except Exception as e:
|
|
||||||
return None, str(e)
|
|
||||||
|
|
||||||
|
|
||||||
def process_stems_to_gokapi(req):
|
|
||||||
"""
|
|
||||||
Download stem files from MusicGPT CDN, zip them, upload the zip to Gokapi,
|
|
||||||
and return (gokapi_url, error_message).
|
|
||||||
|
|
||||||
Also updates the request row with the Gokapi download link in stems_link.
|
|
||||||
"""
|
|
||||||
import io
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
stems_url = req.get('stems_url') or ''
|
|
||||||
urls = parse_stems_urls(stems_url)
|
|
||||||
if not urls:
|
|
||||||
return None, 'No stems URL to process'
|
|
||||||
|
|
||||||
# If there's only one URL and it's already a .zip, upload it directly.
|
|
||||||
if len(urls) == 1 and urls[0][1].endswith('.zip'):
|
|
||||||
try:
|
|
||||||
r = requests.get(urls[0][1], timeout=120)
|
|
||||||
r.raise_for_status()
|
|
||||||
zip_bytes = r.content
|
|
||||||
except Exception as e:
|
|
||||||
return None, f'Failed to download stems zip: {e}'
|
|
||||||
else:
|
|
||||||
# Download each stem file and bundle into a zip in memory.
|
|
||||||
buf = io.BytesIO()
|
|
||||||
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
||||||
for label, url in urls:
|
|
||||||
try:
|
|
||||||
r = requests.get(url, timeout=60)
|
|
||||||
r.raise_for_status()
|
|
||||||
filename = url.split('/')[-1] if '/' in url else f"{label}.mp3"
|
|
||||||
if '.' not in filename:
|
|
||||||
filename = f"{label}.mp3"
|
|
||||||
zf.writestr(filename, r.content)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
zip_bytes = buf.getvalue()
|
|
||||||
|
|
||||||
title = req.get('suno_title') or req.get('name') or f'request_{req["id"]}'
|
|
||||||
zip_filename = f"stems_{title}.zip"
|
|
||||||
|
|
||||||
download_url, error = upload_to_gokapi(zip_bytes, zip_filename, Config.GOKAPI_EXPIRY_DAYS)
|
|
||||||
if error:
|
|
||||||
return None, error
|
|
||||||
|
|
||||||
# Save the Gokapi link to stems_link in the database.
|
|
||||||
update_request(req['id'], stems_link=download_url)
|
|
||||||
return download_url, None
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ Big Beat
|
||||||
Black Metal
|
Black Metal
|
||||||
Bluegrass
|
Bluegrass
|
||||||
Blues
|
Blues
|
||||||
Bollywood
|
|
||||||
Booty Bass
|
Booty Bass
|
||||||
Breakbeat
|
Breakbeat
|
||||||
BritPop
|
BritPop
|
||||||
|
|
|
||||||
11
models.py
11
models.py
|
|
@ -10,11 +10,8 @@ Schema overview (see SCHEMA constant):
|
||||||
- requests table stores customer data, generated prompts, file paths,
|
- requests table stores customer data, generated prompts, file paths,
|
||||||
approval state, email timestamps, payment reference, player token,
|
approval state, email timestamps, payment reference, player token,
|
||||||
vocal gender preference, revision count, revision note, and operator notes.
|
vocal gender preference, revision count, revision note, and operator notes.
|
||||||
- MusicGPT integration fields store task/conversion IDs, status, per-version
|
- MusicGPT integration fields store task/conversion IDs, status, cost, error,
|
||||||
costs (musicgpt_cost_a/b accumulated into musicgpt_cost), error, album cover
|
album cover URL, WAV paths, and stems task/cost/URL.
|
||||||
URL, WAV paths, and stems task/cost/URL.
|
|
||||||
- Stems integration: stems_url holds raw CDN URLs from the webhook, stems_link
|
|
||||||
holds the Gokapi share link (auto-generated on completion or manually uploaded).
|
|
||||||
- Revisions: when a customer requests changes, the current A/B MP3 files are
|
- Revisions: when a customer requests changes, the current A/B MP3 files are
|
||||||
renamed to archived "RevN-" copies and new versions are uploaded later.
|
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
|
- operator_notes is an internal column for the booth team and is never shown
|
||||||
|
|
@ -57,7 +54,7 @@ CREATE TABLE IF NOT EXISTS requests (
|
||||||
revision_count INTEGER DEFAULT 0,
|
revision_count INTEGER DEFAULT 0,
|
||||||
revision_note TEXT,
|
revision_note TEXT,
|
||||||
operator_notes TEXT,
|
operator_notes TEXT,
|
||||||
stems_link TEXT, -- Gokapi share link for stems (auto-generated, also editable by operator)
|
stems_link TEXT,
|
||||||
stems_interest INTEGER DEFAULT 0,
|
stems_interest INTEGER DEFAULT 0,
|
||||||
musicgpt_task_id TEXT, -- MusicGPT Music AI task ID shared by Version A and B
|
musicgpt_task_id TEXT, -- MusicGPT Music AI task ID shared by Version A and B
|
||||||
musicgpt_conversion_id_1 TEXT, -- conversion ID for Version A
|
musicgpt_conversion_id_1 TEXT, -- conversion ID for Version A
|
||||||
|
|
@ -74,7 +71,7 @@ CREATE TABLE IF NOT EXISTS requests (
|
||||||
stems_task_id TEXT, -- MusicGPT Extraction task ID for optional stems
|
stems_task_id TEXT, -- MusicGPT Extraction task ID for optional stems
|
||||||
stems_status TEXT, -- status of the Extraction job
|
stems_status TEXT, -- status of the Extraction job
|
||||||
stems_cost REAL, -- API-reported stems cost in USD credits
|
stems_cost REAL, -- API-reported stems cost in USD credits
|
||||||
stems_url TEXT, -- raw stem URLs from MusicGPT webhook (label: url; label: url)
|
stems_url TEXT, -- download / share link for stems
|
||||||
stems_error TEXT -- error message from stems job
|
stems_error TEXT -- error message from stems job
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,7 @@
|
||||||
margin:.6rem 0;
|
margin:.6rem 0;
|
||||||
padding:.4rem 0;
|
padding:.4rem 0;
|
||||||
}
|
}
|
||||||
.song-row input[type="checkbox"],
|
.song-row input[type="checkbox"]{
|
||||||
.song-row input[type="radio"]{
|
|
||||||
width:auto;
|
width:auto;
|
||||||
margin:.2rem 0 0 0;
|
margin:.2rem 0 0 0;
|
||||||
flex-shrink:0;
|
flex-shrink:0;
|
||||||
|
|
@ -257,9 +256,8 @@
|
||||||
<ol class="steps" aria-label="Request progress">
|
<ol class="steps" aria-label="Request progress">
|
||||||
{% for i in range(steps|length) %}
|
{% for i in range(steps|length) %}
|
||||||
{% set key, label = steps[i] %}
|
{% set key, label = steps[i] %}
|
||||||
{% set is_last = i == steps|length - 1 %}
|
{% set is_completed = i < current_step %}
|
||||||
{% set is_completed = i < current_step or (is_last and current_step == i) %}
|
{% set is_current = i == current_step %}
|
||||||
{% set is_current = i == current_step and not is_last %}
|
|
||||||
<li class="step-item {% if is_completed %}completed{% elif is_current %}current{% else %}upcoming{% endif %}" {% if is_current %}aria-current="step"{% endif %}>
|
<li class="step-item {% if is_completed %}completed{% elif is_current %}current{% else %}upcoming{% endif %}" {% if is_current %}aria-current="step"{% endif %}>
|
||||||
<div class="step-indicator">
|
<div class="step-indicator">
|
||||||
{% if is_completed %}✓{% else %}{{ i + 1 }}{% endif %}
|
{% if is_completed %}✓{% else %}{{ i + 1 }}{% endif %}
|
||||||
|
|
@ -284,11 +282,10 @@
|
||||||
{% if mg_steps[j][0] == current_mg %}{% set ns.mg_index = j %}{% endif %}
|
{% if mg_steps[j][0] == current_mg %}{% set ns.mg_index = j %}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if current_mg in ('FAILED', 'ERROR', 'CANCELLED') %}{% set ns.mg_index = -2 %}{% endif %}
|
{% if current_mg in ('FAILED', 'ERROR', 'CANCELLED') %}{% set ns.mg_index = -2 %}{% endif %}
|
||||||
{% set mg_completed_step = req.musicgpt_status in ('COMPLETED', 'FINISHED') %}
|
|
||||||
{% for j in range(mg_steps|length) %}
|
{% for j in range(mg_steps|length) %}
|
||||||
{% set mg_key, mg_label = mg_steps[j] %}
|
{% set mg_key, mg_label = mg_steps[j] %}
|
||||||
{% set mg_completed = j < ns.mg_index or mg_completed_step %}
|
{% set mg_completed = j < ns.mg_index %}
|
||||||
{% set mg_current = j == ns.mg_index and not mg_completed_step %}
|
{% set mg_current = j == ns.mg_index %}
|
||||||
<li class="step-item {% if mg_completed %}completed{% elif mg_current %}current{% else %}upcoming{% endif %} {% if ns.mg_index == -2 and not mg_completed and not mg_current %}upcoming{% endif %}" {% if mg_current %}aria-current="step"{% endif %}>
|
<li class="step-item {% if mg_completed %}completed{% elif mg_current %}current{% else %}upcoming{% endif %} {% if ns.mg_index == -2 and not mg_completed and not mg_current %}upcoming{% endif %}" {% if mg_current %}aria-current="step"{% endif %}>
|
||||||
<div class="step-indicator">
|
<div class="step-indicator">
|
||||||
{% if mg_completed %}✓{% else %}{{ j + 1 }}{% endif %}
|
{% if mg_completed %}✓{% else %}{{ j + 1 }}{% endif %}
|
||||||
|
|
@ -454,16 +451,13 @@
|
||||||
<!-- Stems / Extras Link -->
|
<!-- Stems / Extras Link -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Stems / Extras Link</h2>
|
<h2>Stems / Extras Link</h2>
|
||||||
<p class="copy-hint">Auto-generated from Gokapi when stems are completed. You can also paste a custom share link here — it will appear in the delivery email.</p>
|
<p class="copy-hint">Paste a self-hosted file share link (e.g. from Pingvin Share). It will appear on the customer player page once delivered.</p>
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="action" value="save_stems_link">
|
<input type="hidden" name="action" value="save_stems_link">
|
||||||
<label for="stems_link">Share link</label>
|
<label for="stems_link">Share link</label>
|
||||||
<input type="url" id="stems_link" name="stems_link" value="{{ req.stems_link or '' }}" placeholder="https://files.dionysismedia.ca/d?id=...">
|
<input type="url" id="stems_link" name="stems_link" value="{{ req.stems_link or '' }}" placeholder="https://files.dionysismedia.ca/share/...">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit" class="secondary">Save Link</button>
|
<button type="submit" class="secondary">Save Link</button>
|
||||||
{% if req.stems_url and not req.stems_link %}
|
|
||||||
<button type="submit" name="action" value="reprocess_stems" class="secondary">Re-upload to Gokapi</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -473,11 +467,8 @@
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<!-- Generate Music Prompt -->
|
<!-- Generate Music Prompt -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>1. Generate & Save Music Prompt</h2>
|
<h2>1. Generate & Save Music Prompt</h2>
|
||||||
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
|
<button type="button" onclick="copyPromptForHermes()">Copy customer info for Hermes</button>
|
||||||
<form method="POST" action="{{ url_for('send_to_hermes', rid=req.id) }}" style="display:inline">
|
|
||||||
<button type="submit" onclick="return confirm('Send this request to Hermes to generate a MusicGPT prompt?')">🚀 Send to Hermes</button>
|
|
||||||
</form>
|
|
||||||
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save or generate.</p>
|
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save or generate.</p>
|
||||||
|
|
||||||
{% if req.musicgpt_error %}
|
{% if req.musicgpt_error %}
|
||||||
|
|
@ -632,11 +623,7 @@
|
||||||
{% if req.stems_error %}
|
{% if req.stems_error %}
|
||||||
<div class="flash error">{{ req.stems_error }}</div>
|
<div class="flash error">{{ req.stems_error }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if req.stems_link %}
|
{% if req.stems_url %}
|
||||||
<div class="actions">
|
|
||||||
<a href="{{ req.stems_link }}" target="_blank" rel="noopener noreferrer" class="button-link" style="display:inline-flex;align-items:center;gap:.4rem;padding:.5rem 1rem;background:#4b5563;border-radius:.375rem;color:#fff;text-decoration:none;font-size:.875rem">⬇ Download Stems</a>
|
|
||||||
</div>
|
|
||||||
{% elif req.stems_url %}
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="{{ url_for('admin_download_stems', rid=req.id) }}" class="button-link" style="display:inline-flex;align-items:center;gap:.4rem;padding:.5rem 1rem;background:#4b5563;border-radius:.375rem;color:#fff;text-decoration:none;font-size:.875rem">⬇ Download Stems (zip)</a>
|
<a href="{{ url_for('admin_download_stems', rid=req.id) }}" class="button-link" style="display:inline-flex;align-items:center;gap:.4rem;padding:.5rem 1rem;background:#4b5563;border-radius:.375rem;color:#fff;text-decoration:none;font-size:.875rem">⬇ Download Stems (zip)</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -655,7 +642,7 @@
|
||||||
<p>
|
<p>
|
||||||
Preview email:
|
Preview email:
|
||||||
{% if req.preview_sent_at %}
|
{% if req.preview_sent_at %}
|
||||||
<span class="status-badge sent">✅ Sent</span> {{ req.preview_sent_at|regina_dt }}
|
<span class="status-badge sent">✅ Sent</span> {{ req.preview_sent_at }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="status-badge missing">❌ Not sent yet</span>
|
<span class="status-badge missing">❌ Not sent yet</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -688,7 +675,7 @@
|
||||||
<p>
|
<p>
|
||||||
Delivery email:
|
Delivery email:
|
||||||
{% if req.delivery_sent_at %}
|
{% if req.delivery_sent_at %}
|
||||||
<span class="status-badge sent">✅ Sent</span> {{ req.delivery_sent_at|regina_dt }}
|
<span class="status-badge sent">✅ Sent</span> {{ req.delivery_sent_at }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="status-badge missing">❌ Not sent yet</span>
|
<span class="status-badge missing">❌ Not sent yet</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -339,37 +339,6 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hermes webhook integration -->
|
|
||||||
<div class="section">
|
|
||||||
<h2>Hermes Webhook</h2>
|
|
||||||
<p class="copy-hint">Push song request data to Hermes so it can generate a MusicGPT prompt automatically. The URL must include the route name (e.g. https://ntfy.hallsworth.ca/webhooks/trollgorithm-song-info). The secret is encrypted before storage.</p>
|
|
||||||
<form method="POST">
|
|
||||||
<input type="hidden" name="action" value="save_hermes_webhook">
|
|
||||||
<table class="meta-table">
|
|
||||||
<tr>
|
|
||||||
<td>Webhook URL</td>
|
|
||||||
<td><input type="text" id="hermes_webhook_url" name="hermes_webhook_url" value="{{ hermes_webhook.get('url', '') }}" placeholder="https://ntfy.hallsworth.ca/webhooks/trollgorithm-song-info"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>HMAC Secret</td>
|
|
||||||
<td>
|
|
||||||
<input type="password" id="hermes_webhook_secret" name="hermes_webhook_secret" placeholder="{% if hermes_webhook.get('secret') %}Stored encrypted — type to replace{% else %}Enter secret{% endif %}">
|
|
||||||
<p class="copy-hint">From the Hermes webhook subscription. Leave blank to keep the existing stored secret.</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<button type="submit">Save Hermes Webhook</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0;">
|
|
||||||
|
|
||||||
<h3>Send Test Request</h3>
|
|
||||||
<form method="POST">
|
|
||||||
<input type="hidden" name="action" value="send_test_hermes_webhook">
|
|
||||||
<button type="submit">Send Test to Hermes</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- MusicGPT integration -->
|
<!-- MusicGPT integration -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>MusicGPT Integration</h2>
|
<h2>MusicGPT Integration</h2>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue