Integrate MusicGPT API generation into booth-musicgpt v0.7.0
This commit is contained in:
parent
99855b1212
commit
2ca148be9b
12 changed files with 721 additions and 44 deletions
45
README.md
45
README.md
|
|
@ -1,8 +1,10 @@
|
||||||
# Theme Song Booth
|
# Theme Song Booth (MusicGPT Edition)
|
||||||
|
|
||||||
**Version:** `v0.6.3`
|
**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 Suno prompts, upload MP3 previews, collect payment, and deliver final songs by email.
|
A Flask web application for running a convention booth where visitors request a custom AI-generated theme song. Operators manage the queue from an admin dashboard, generate 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -25,12 +27,12 @@ A Flask web application for running a convention booth where visitors request a
|
||||||
## What the booth does
|
## What the booth does
|
||||||
|
|
||||||
1. A visitor fills out a short form at `/request`.
|
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.
|
2. The operator reviews the request in the admin dashboard and generates a prompt for MusicGPT (manually or via the `/api/prompt` Hermes callback).
|
||||||
3. The operator (or an AI assistant via the `/api/prompt` callback) saves the prompt to the request.
|
3. The operator saves the title, style, and lyrics 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.
|
4. The operator clicks **Generate A/B with MusicGPT**, selects a model, and waits for the webhook to complete. MusicGPT produces two MP3 versions, 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) 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 / extras can be delivered via a share link that appears on the player page after delivery.
|
7. Optional STEMS can be generated for an extra fee and delivered via a share link.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -74,10 +76,10 @@ The selected pronouns are stored in the `pronouns` column and included in confir
|
||||||
|------|------|---------|
|
|------|------|---------|
|
||||||
| Login | `/admin/login` | Simple session-based login. Password comes from the `ADMIN_PASSWORD` environment variable. |
|
| 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. |
|
| Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. |
|
||||||
| Request detail | `/admin/request/<id>` | 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. |
|
| Request detail | `/admin/request/<id>` | Full control of one request: edit customer info, save prompt, copy Hermes callback, queue MusicGPT generation, view status/cost, view revision history, upload MP3 overrides, send preview, record payment, deliver files (MP3/WAV/cover), add operator notes, cancel generation, and generate STEMS. |
|
||||||
| Pricing | `/admin/pricing` | Configure fixed prices (one song, both songs, WAV per song, STEMs per song) and up to 5 custom items. |
|
| Pricing | `/admin/pricing` | Configure fixed prices and up to 5 custom items. Also shows running MusicGPT cost totals in USD. |
|
||||||
| Sales | `/admin/sales` | Report of all delivered requests with customer details and Square payment references. |
|
| 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. |
|
| 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, MusicGPT webhook URL, album cover delivery toggle, and system reset. |
|
||||||
| Reset | `/admin/reset` | Clears all requests and uploaded files. Requires admin password confirmation. |
|
| Reset | `/admin/reset` | Clears all requests and uploaded files. Requires admin password confirmation. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -90,9 +92,9 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de
|
||||||
|
|
||||||
| Status | Meaning |
|
| Status | Meaning |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `pending` | Customer submitted; waiting for a Suno prompt. |
|
| `pending` | Customer submitted; waiting for a prompt. |
|
||||||
| `prompt_ready` | Prompt saved; ready to generate songs. |
|
| `prompt_ready` | Prompt saved; ready to generate songs. |
|
||||||
| `songs_uploaded` | Both MP3s uploaded; preview link can be sent. |
|
| `songs_uploaded` | Both MP3s are ready; preview link can be sent. |
|
||||||
| `revisions_requested` | Customer asked for changes; current files archived. |
|
| `revisions_requested` | Customer asked for changes; current files archived. |
|
||||||
| `awaiting_payment` | Customer approved a version; waiting for payment. |
|
| `awaiting_payment` | Customer approved a version; waiting for payment. |
|
||||||
| `paid` | Payment recorded. |
|
| `paid` | Payment recorded. |
|
||||||
|
|
@ -162,20 +164,20 @@ Use the output for `APP_SECRET_KEY`.
|
||||||
1. Log in to Portainer.
|
1. Log in to Portainer.
|
||||||
2. Go to **Stacks** → **Add stack**.
|
2. Go to **Stacks** → **Add stack**.
|
||||||
3. Choose **Repository**:
|
3. Choose **Repository**:
|
||||||
- URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git`
|
- URL: `https://gitlab.hallsworth.ca/yrtria/booth-musicgpt.git`
|
||||||
- Branch: `main`
|
- Branch: `main`
|
||||||
- Compose path: `docker-compose.yml`
|
- Compose path: `docker-compose.yml`
|
||||||
4. Add the environment variables listed in the section below.
|
4. Add the environment variables listed in the section below.
|
||||||
5. Deploy the stack.
|
5. Deploy the stack.
|
||||||
6. Open a console in the running `theme-song-booth` container and run once:
|
6. Open a console in the running `booth-musicgpt` container and run once:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python init_db.py
|
python init_db.py
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Point your reverse proxy at the host port you chose (default `127.0.0.1:8000`).
|
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**.
|
8. Visit `/admin/settings`, confirm the MusicGPT webhook URL, and configure the MusicGPT account webhook endpoint.
|
||||||
9. Copy the 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`.
|
||||||
|
|
||||||
### Updating the deployment
|
### Updating the deployment
|
||||||
|
|
@ -183,7 +185,7 @@ python init_db.py
|
||||||
After each push to Gitea:
|
After each push to Gitea:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Portainer → Stacks → theme-song-booth → Pull and redeploy
|
Portainer → Stacks → booth-musicgpt → Pull and redeploy
|
||||||
```
|
```
|
||||||
|
|
||||||
Persistent volumes keep the database and uploads safe across redeploys.
|
Persistent volumes keep the database and uploads safe across redeploys.
|
||||||
|
|
@ -208,9 +210,10 @@ Persistent volumes keep the database and uploads safe across redeploys.
|
||||||
| `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. If omitted, generate one from `/admin/settings`. |
|
| `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 MP3 storage inside the container. |
|
| `UPLOAD_FOLDER` | No | `/app/uploads` | Path to uploaded/downloaded song storage inside the container. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -235,7 +238,7 @@ Persistent volumes keep the database and uploads safe across redeploys.
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /home/jess/workspace/theme-song-booth
|
cd /home/jess/workspace/booth-musicgpt
|
||||||
python3 -m venv .venv
|
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
|
||||||
|
|
|
||||||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.6.3
|
0.7.0
|
||||||
|
|
|
||||||
270
app.py
270
app.py
|
|
@ -33,6 +33,7 @@ import os
|
||||||
import hmac
|
import hmac
|
||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Flask and related imports
|
# Flask and related imports
|
||||||
|
|
@ -56,6 +57,11 @@ from helpers import (
|
||||||
get_email_config, get_refresh_seconds, get_kiosk_cycle_seconds, get_kiosk_mode,
|
get_email_config, get_refresh_seconds, get_kiosk_cycle_seconds, get_kiosk_mode,
|
||||||
get_max_revisions, get_callback_expiry_hours,
|
get_max_revisions, get_callback_expiry_hours,
|
||||||
get_hermes_api_key, set_hermes_api_key, generate_hermes_api_key, mask_api_key,
|
get_hermes_api_key, set_hermes_api_key, generate_hermes_api_key, mask_api_key,
|
||||||
|
get_musicgpt_api_key, get_musicgpt_default_model, get_musicgpt_models,
|
||||||
|
build_musicgpt_webhook_url,
|
||||||
|
musicgpt_generate_request, musicgpt_queue_stems, musicgpt_poll_status,
|
||||||
|
download_musicgpt_outputs, format_musicgpt_cost, get_musicgpt_cost_totals,
|
||||||
|
download_album_cover,
|
||||||
get_ntfy_config, send_ntfy,
|
get_ntfy_config, send_ntfy,
|
||||||
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,
|
||||||
|
|
@ -272,6 +278,130 @@ def api_update_prompt(rid):
|
||||||
return jsonify({'ok': True, 'request_id': rid, 'status': new_status}), 200
|
return jsonify({'ok': True, 'request_id': rid, 'status': new_status}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/musicgpt/webhook', methods=['POST'])
|
||||||
|
def musicgpt_webhook():
|
||||||
|
"""
|
||||||
|
Public webhook endpoint for MusicGPT async job completion.
|
||||||
|
|
||||||
|
Handles both Music AI generation webhooks and Extraction (stems) webhooks.
|
||||||
|
Generation webhooks may arrive multiple times per task (one per version,
|
||||||
|
plus lyrics, plus streaming URL, plus album cover). We update the request
|
||||||
|
incrementally and download files when a COMPLETED conversion payload arrives.
|
||||||
|
"""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
task_id = data.get('task_id') or data.get('taskId')
|
||||||
|
conversion_type = data.get('conversion_type') or data.get('conversionType') or ''
|
||||||
|
status = data.get('status') or data.get('conversion_status') or 'COMPLETED'
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
return jsonify({'ok': False, 'reason': 'missing_task_id'}), 400
|
||||||
|
|
||||||
|
conversion_id = data.get('conversion_id')
|
||||||
|
if not conversion_id and 'conversion_id_1' in data:
|
||||||
|
conversion_id = data.get('conversion_id_1')
|
||||||
|
|
||||||
|
# Music AI generation webhook
|
||||||
|
if 'Music' in conversion_type or 'Music AI' in conversion_type:
|
||||||
|
req = None
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute('SELECT * FROM requests WHERE musicgpt_task_id = ?', (task_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
req = dict(row)
|
||||||
|
if not req:
|
||||||
|
return jsonify({'ok': False, 'reason': 'request_not_found'}), 404
|
||||||
|
|
||||||
|
new_status = status.upper()
|
||||||
|
update_fields = {'musicgpt_status': new_status}
|
||||||
|
if data.get('is_flagged'):
|
||||||
|
update_fields['musicgpt_error'] = data.get('reason') or 'Flagged by MusicGPT'
|
||||||
|
if new_status in ('COMPLETED', 'FINISHED'):
|
||||||
|
download_musicgpt_outputs(req, data)
|
||||||
|
# Re-fetch to get updated paths.
|
||||||
|
row = db.execute('SELECT * FROM requests WHERE id = ?', (req['id'],)).fetchone()
|
||||||
|
req = dict(row)
|
||||||
|
if req.get('song_a_path') and req.get('song_b_path'):
|
||||||
|
update_fields['status'] = 'songs_uploaded'
|
||||||
|
elif new_status in ('FAILED', 'ERROR'):
|
||||||
|
update_fields['musicgpt_error'] = data.get('reason') or data.get('error') or 'MusicGPT reported failure'
|
||||||
|
update_request(req['id'], **update_fields)
|
||||||
|
return jsonify({'ok': True, 'request_id': req['id']}), 200
|
||||||
|
|
||||||
|
# Extraction / stems webhook
|
||||||
|
if 'Extraction' in conversion_type:
|
||||||
|
req = None
|
||||||
|
db = get_db()
|
||||||
|
row = db.execute('SELECT * FROM requests WHERE stems_task_id = ?', (task_id,)).fetchone()
|
||||||
|
if row:
|
||||||
|
req = dict(row)
|
||||||
|
if not req:
|
||||||
|
return jsonify({'ok': False, 'reason': 'request_not_found'}), 404
|
||||||
|
|
||||||
|
new_status = status.upper()
|
||||||
|
update_fields = {'stems_status': new_status}
|
||||||
|
if new_status in ('COMPLETED', 'FINISHED'):
|
||||||
|
audio_url_map = data.get('audio_url')
|
||||||
|
if isinstance(audio_url_map, str):
|
||||||
|
try:
|
||||||
|
audio_url_map = json.loads(audio_url_map)
|
||||||
|
except Exception:
|
||||||
|
audio_url_map = {}
|
||||||
|
# Prefer a zip-style bundle URL if present, otherwise join stem URLs.
|
||||||
|
stems_url = data.get('bundle_url') or data.get('download_url')
|
||||||
|
if not stems_url and audio_url_map:
|
||||||
|
stems_url = '; '.join(f"{k}: {v}" for k, v in audio_url_map.items())
|
||||||
|
update_fields['stems_url'] = stems_url
|
||||||
|
update_fields['stems_link'] = stems_url
|
||||||
|
try:
|
||||||
|
update_fields['stems_cost'] = float(data.get('conversion_cost') or 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
elif new_status in ('FAILED', 'ERROR'):
|
||||||
|
update_fields['stems_error'] = data.get('reason') or data.get('error') or 'Extraction failed'
|
||||||
|
update_request(req['id'], **update_fields)
|
||||||
|
return jsonify({'ok': True, 'request_id': req['id']}), 200
|
||||||
|
|
||||||
|
return jsonify({'ok': False, 'reason': 'unknown_conversion_type'}), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/admin/musicgpt/refresh', methods=['POST'])
|
||||||
|
def admin_musicgpt_refresh():
|
||||||
|
"""Manual dashboard action: poll all in-flight MusicGPT tasks and update statuses."""
|
||||||
|
redir = require_admin()
|
||||||
|
if redir:
|
||||||
|
return redir
|
||||||
|
db = get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
"SELECT id, musicgpt_task_id, musicgpt_status FROM requests WHERE musicgpt_status IN ('IN_QUEUE', 'IN_PROGRESS')"
|
||||||
|
).fetchall()
|
||||||
|
updated = 0
|
||||||
|
failed = 0
|
||||||
|
for row in rows:
|
||||||
|
task_id = row['musicgpt_task_id']
|
||||||
|
if not task_id:
|
||||||
|
continue
|
||||||
|
result = musicgpt_poll_status(task_id)
|
||||||
|
conversion = result.get('conversion') or {}
|
||||||
|
status = (conversion.get('status') or result.get('status') or '').upper()
|
||||||
|
req = get_request_by_id(row['id'])
|
||||||
|
if not req:
|
||||||
|
continue
|
||||||
|
if status in ('COMPLETED', 'FINISHED'):
|
||||||
|
download_musicgpt_outputs(req, conversion)
|
||||||
|
fields = {'musicgpt_status': 'COMPLETED'}
|
||||||
|
if req.get('song_a_path') and req.get('song_b_path'):
|
||||||
|
fields['status'] = 'songs_uploaded'
|
||||||
|
update_request(row['id'], **fields)
|
||||||
|
updated += 1
|
||||||
|
elif status in ('FAILED', 'ERROR'):
|
||||||
|
update_request(row['id'], musicgpt_status='FAILED', musicgpt_error=conversion.get('status_msg') or 'Polling reported failure')
|
||||||
|
failed += 1
|
||||||
|
elif status:
|
||||||
|
update_request(row['id'], musicgpt_status=status)
|
||||||
|
updated += 1
|
||||||
|
flash(f'MusicGPT refresh: {updated} updated, {failed} failed.', 'success' if not failed else 'warning')
|
||||||
|
return redirect(url_for('admin_dashboard'))
|
||||||
|
|
||||||
|
|
||||||
@app.route('/thanks/<int:rid>')
|
@app.route('/thanks/<int:rid>')
|
||||||
def thanks(rid):
|
def thanks(rid):
|
||||||
"""Confirmation page shown after a customer submits a request."""
|
"""Confirmation page shown after a customer submits a request."""
|
||||||
|
|
@ -573,7 +703,8 @@ def admin_pricing():
|
||||||
'name': entry.get('name', '') if isinstance(entry, dict) else '',
|
'name': entry.get('name', '') if isinstance(entry, dict) else '',
|
||||||
'price': entry.get('price', '') if isinstance(entry, dict) else ''
|
'price': entry.get('price', '') if isinstance(entry, dict) else ''
|
||||||
})
|
})
|
||||||
return render_template('admin/pricing.html', fixed=fixed, customs=customs)
|
cost_totals = get_musicgpt_cost_totals()
|
||||||
|
return render_template('admin/pricing.html', fixed=fixed, customs=customs, cost_totals=cost_totals)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/kiosk')
|
@app.route('/kiosk')
|
||||||
|
|
@ -743,6 +874,94 @@ def admin_request(rid):
|
||||||
)
|
)
|
||||||
flash('Prompt saved.', 'success')
|
flash('Prompt saved.', 'success')
|
||||||
|
|
||||||
|
elif action == 'generate_musicgpt':
|
||||||
|
# Queue a MusicGPT generation job.
|
||||||
|
api_key = get_musicgpt_api_key()
|
||||||
|
if not api_key:
|
||||||
|
flash('MusicGPT API key is not configured.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
|
if req.get('musicgpt_status') in ('IN_QUEUE', 'IN_PROGRESS'):
|
||||||
|
flash('A MusicGPT generation is already in progress for this request.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
|
title = request.form.get('suno_title', '').strip()
|
||||||
|
style = request.form.get('suno_style', '').strip()
|
||||||
|
lyrics = request.form.get('suno_lyrics', '').strip()
|
||||||
|
if not (title and style and lyrics):
|
||||||
|
flash('Title, style, and lyrics are required to generate music.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
|
model = request.form.get('musicgpt_model', get_musicgpt_default_model()).strip()
|
||||||
|
if model not in get_musicgpt_models():
|
||||||
|
flash('Invalid MusicGPT model selected.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
|
gender = request.form.get('vocal_gender', req.get('vocal_gender') or '').strip()
|
||||||
|
task_id, conv1, conv2, estimate, error = musicgpt_generate_request(
|
||||||
|
rid, title, style, lyrics, gender=gender, model=model
|
||||||
|
)
|
||||||
|
if error:
|
||||||
|
update_request(rid, musicgpt_status='ERROR', musicgpt_error=error)
|
||||||
|
flash(f'Failed to queue MusicGPT generation: {error}', 'error')
|
||||||
|
else:
|
||||||
|
update_request(rid,
|
||||||
|
suno_title=title,
|
||||||
|
suno_style=style,
|
||||||
|
suno_lyrics=lyrics,
|
||||||
|
musicgpt_task_id=task_id,
|
||||||
|
musicgpt_status='IN_QUEUE',
|
||||||
|
musicgpt_error=None,
|
||||||
|
musicgpt_cost=estimate,
|
||||||
|
status='prompt_ready',
|
||||||
|
deliver_wav=1 if request.form.get('deliver_wav') else 0
|
||||||
|
)
|
||||||
|
flash(f'MusicGPT generation queued (task {task_id}). Estimated cost: {estimate}', 'success')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
|
elif action == 'save_delivery_options':
|
||||||
|
update_request(rid, deliver_wav=1 if request.form.get('deliver_wav') else 0)
|
||||||
|
flash('Delivery options saved.', 'success')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
|
elif action == 'cancel_musicgpt':
|
||||||
|
# Mark an in-flight MusicGPT job as cancelled locally.
|
||||||
|
if req.get('musicgpt_status') in ('IN_QUEUE', 'IN_PROGRESS'):
|
||||||
|
update_request(rid, musicgpt_status='CANCELLED')
|
||||||
|
flash('MusicGPT generation marked as cancelled.', 'success')
|
||||||
|
else:
|
||||||
|
flash('No in-progress MusicGPT generation to cancel.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
|
elif action == 'generate_stems':
|
||||||
|
# Queue a stem extraction job for Version A MP3.
|
||||||
|
api_key = get_musicgpt_api_key()
|
||||||
|
if not api_key:
|
||||||
|
flash('MusicGPT API key is not configured.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
if req.get('stems_status') in ('IN_QUEUE', 'IN_PROGRESS'):
|
||||||
|
flash('A stems extraction is already in progress.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
audio_url = request.form.get('stems_source_url', '').strip()
|
||||||
|
if not audio_url:
|
||||||
|
# Fall back to using the locally stored Version A MP3 path; upload not supported by extraction API.
|
||||||
|
a_path = req.get('song_a_path')
|
||||||
|
if a_path:
|
||||||
|
# Extraction API supports audio_url; we need a public URL. Local path won't work.
|
||||||
|
flash('Please provide a public audio URL for stems extraction.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
flash('No Version A audio available to extract stems from.', 'error')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
stems = request.form.getlist('stems') or ['vocals', 'instrumental']
|
||||||
|
task_id, conv_id, estimate, error = musicgpt_queue_stems(rid, audio_url, stems=stems)
|
||||||
|
if error:
|
||||||
|
update_request(rid, stems_status='ERROR', stems_error=error)
|
||||||
|
flash(f'Failed to queue stems extraction: {error}', 'error')
|
||||||
|
else:
|
||||||
|
update_request(rid, stems_task_id=task_id, stems_status='IN_QUEUE', stems_error=None, stems_cost=estimate)
|
||||||
|
flash(f'Stems extraction queued (task {task_id}). Estimated cost: {estimate}', 'success')
|
||||||
|
return redirect(url_for('admin_request', rid=rid))
|
||||||
|
|
||||||
elif action == 'delete_songs':
|
elif action == 'delete_songs':
|
||||||
# Remove selected uploaded MP3s and reset status so new songs can be uploaded.
|
# Remove selected uploaded MP3s and reset status so new songs can be uploaded.
|
||||||
to_delete = request.form.getlist('delete_song')
|
to_delete = request.form.getlist('delete_song')
|
||||||
|
|
@ -754,20 +973,34 @@ def admin_request(rid):
|
||||||
for version in to_delete:
|
for version in to_delete:
|
||||||
if version == 'a':
|
if version == 'a':
|
||||||
path = req.get('song_a_path')
|
path = req.get('song_a_path')
|
||||||
|
wav = req.get('song_a_wav_path')
|
||||||
if path and Path(path).exists():
|
if path and Path(path).exists():
|
||||||
try:
|
try:
|
||||||
Path(path).unlink()
|
Path(path).unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
if wav and Path(wav).exists():
|
||||||
|
try:
|
||||||
|
Path(wav).unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
update_fields['song_a_path'] = None
|
update_fields['song_a_path'] = None
|
||||||
|
update_fields['song_a_wav_path'] = None
|
||||||
elif version == 'b':
|
elif version == 'b':
|
||||||
path = req.get('song_b_path')
|
path = req.get('song_b_path')
|
||||||
|
wav = req.get('song_b_wav_path')
|
||||||
if path and Path(path).exists():
|
if path and Path(path).exists():
|
||||||
try:
|
try:
|
||||||
Path(path).unlink()
|
Path(path).unlink()
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
if wav and Path(wav).exists():
|
||||||
|
try:
|
||||||
|
Path(wav).unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
update_fields['song_b_path'] = None
|
update_fields['song_b_path'] = None
|
||||||
|
update_fields['song_b_wav_path'] = None
|
||||||
|
|
||||||
if update_fields:
|
if update_fields:
|
||||||
# Wipe any prior customer approval because the old files are gone.
|
# Wipe any prior customer approval because the old files are gone.
|
||||||
|
|
@ -881,6 +1114,20 @@ def admin_request(rid):
|
||||||
body_lines.insert(5, f"This share link expires on {expiry_date} (3 months from today). Please download before then.")
|
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_lines.insert(6, "")
|
||||||
body = '\n'.join(body_lines)
|
body = '\n'.join(body_lines)
|
||||||
|
|
||||||
|
# Optionally attach WAV files and album cover.
|
||||||
|
if req.get('deliver_wav'):
|
||||||
|
for wav_field in ('song_a_wav_path', 'song_b_wav_path'):
|
||||||
|
wav_path = req.get(wav_field)
|
||||||
|
if wav_path and Path(wav_path).exists():
|
||||||
|
p = Path(wav_path)
|
||||||
|
attachments.append((str(p), p.name))
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
if cfg.get('deliver_album_cover') and req.get('album_cover_url'):
|
||||||
|
cover_path = download_album_cover(rid, req['album_cover_url'])
|
||||||
|
if cover_path:
|
||||||
|
attachments.append((cover_path, f"cover{Path(cover_path).suffix}"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
send_email(req['email'], 'Your theme song files are here!', body, attachments=attachments, inline_images=build_signature_images())
|
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())
|
update_request(rid, delivery_sent_at=now_utc())
|
||||||
|
|
@ -901,7 +1148,11 @@ def admin_request(rid):
|
||||||
basename=basename,
|
basename=basename,
|
||||||
extra_files=extra_files,
|
extra_files=extra_files,
|
||||||
callback_url=build_prompt_callback_url(rid),
|
callback_url=build_prompt_callback_url(rid),
|
||||||
revision_history=list_revision_history(rid)
|
revision_history=list_revision_history(rid),
|
||||||
|
musicgpt_models=get_musicgpt_models(),
|
||||||
|
musicgpt_default_model=get_musicgpt_default_model(),
|
||||||
|
format_musicgpt_cost=format_musicgpt_cost,
|
||||||
|
deliver_album_cover=load_booth_settings().get('deliver_album_cover', False)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1010,7 +1261,10 @@ def admin_settings():
|
||||||
'style_genre', 'extra_requests', 'vocal_gender', 'status', 'suno_title', 'suno_style',
|
'style_genre', 'extra_requests', 'vocal_gender', 'status', 'suno_title', 'suno_style',
|
||||||
'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved',
|
'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved',
|
||||||
'approval_notified_at', 'preview_sent_at', 'delivery_sent_at',
|
'approval_notified_at', 'preview_sent_at', 'delivery_sent_at',
|
||||||
'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count', 'operator_notes', 'stems_link', 'stems_interest'
|
'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count', 'operator_notes', 'stems_link', 'stems_interest',
|
||||||
|
'musicgpt_task_id', 'musicgpt_status', 'musicgpt_cost', 'musicgpt_error',
|
||||||
|
'album_cover_url', 'song_a_wav_path', 'song_b_wav_path', 'deliver_wav',
|
||||||
|
'stems_task_id', 'stems_status', 'stems_cost', 'stems_url', 'stems_error'
|
||||||
}
|
}
|
||||||
expected_tables = {'requests', 'revision_history'}
|
expected_tables = {'requests', 'revision_history'}
|
||||||
health = {'ok': True, 'missing_columns': [], 'missing_tables': [], 'message': 'Database schema looks good.'}
|
health = {'ok': True, 'missing_columns': [], 'missing_tables': [], 'message': 'Database schema looks good.'}
|
||||||
|
|
@ -1260,6 +1514,13 @@ def admin_settings():
|
||||||
flash('Failed to send ntfy test notification. Check server, topic, and access token.', 'error')
|
flash('Failed to send ntfy test notification. Check server, topic, and access token.', 'error')
|
||||||
return redirect(url_for('admin_settings'))
|
return redirect(url_for('admin_settings'))
|
||||||
|
|
||||||
|
elif action == 'save_album_cover':
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
cfg['deliver_album_cover'] = request.form.get('deliver_album_cover', '0') == '1'
|
||||||
|
save_booth_settings(cfg)
|
||||||
|
flash('Album cover delivery setting saved.', 'success')
|
||||||
|
return redirect(url_for('admin_settings'))
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'admin/settings.html',
|
'admin/settings.html',
|
||||||
health=health,
|
health=health,
|
||||||
|
|
@ -1289,6 +1550,9 @@ def admin_settings():
|
||||||
version=current_app.config['VERSION'],
|
version=current_app.config['VERSION'],
|
||||||
current_callback_expiry_hours=get_callback_expiry_hours(),
|
current_callback_expiry_hours=get_callback_expiry_hours(),
|
||||||
ntfy=runtime_settings,
|
ntfy=runtime_settings,
|
||||||
|
deliver_album_cover=runtime_settings.get('deliver_album_cover', False),
|
||||||
|
musicgpt_api_key_set=bool(get_musicgpt_api_key()),
|
||||||
|
musicgpt_webhook_url=build_musicgpt_webhook_url(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
12
config.py
12
config.py
|
|
@ -97,3 +97,15 @@ class Config:
|
||||||
|
|
||||||
# Package version, read from VERSION file.
|
# Package version, read from VERSION file.
|
||||||
VERSION = _load_version()
|
VERSION = _load_version()
|
||||||
|
|
||||||
|
# MusicGPT API key (env only; never stored in repo).
|
||||||
|
MUSICGPT_API_KEY = os.environ.get('MUSICGPT_API_KEY', '')
|
||||||
|
|
||||||
|
# Available MusicGPT generation models.
|
||||||
|
MUSICGPT_MODELS = ['v6', 'v6-pro', 'v7', 'v7-pro']
|
||||||
|
MUSICGPT_DEFAULT_MODEL = os.environ.get('MUSICGPT_DEFAULT_MODEL', 'v7-pro')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def musicgpt_webhook_base_url(cls):
|
||||||
|
"""Return the public base URL used for MusicGPT webhooks."""
|
||||||
|
return cls.PUBLIC_BASE_URL.rstrip('/')
|
||||||
|
|
|
||||||
224
helpers.py
224
helpers.py
|
|
@ -33,6 +33,8 @@ from mutagen.easyid3 import EasyID3
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
from models import update_request
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Genre / decade helpers
|
# Genre / decade helpers
|
||||||
|
|
@ -334,6 +336,29 @@ def get_callback_expiry_hours():
|
||||||
return max(1, val)
|
return max(1, val)
|
||||||
|
|
||||||
|
|
||||||
|
def get_musicgpt_api_key():
|
||||||
|
"""Return the MusicGPT API key from env only."""
|
||||||
|
return current_app.config.get('MUSICGPT_API_KEY', '')
|
||||||
|
|
||||||
|
|
||||||
|
def get_musicgpt_default_model():
|
||||||
|
"""Return the configured default MusicGPT model."""
|
||||||
|
default = current_app.config.get('MUSICGPT_DEFAULT_MODEL', 'v7-pro')
|
||||||
|
models = get_musicgpt_models()
|
||||||
|
return default if default in models else models[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def get_musicgpt_models():
|
||||||
|
"""Return the list of supported MusicGPT models."""
|
||||||
|
return list(current_app.config.get('MUSICGPT_MODELS', ['v6', 'v6-pro', 'v7', 'v7-pro']))
|
||||||
|
|
||||||
|
|
||||||
|
def build_musicgpt_webhook_url():
|
||||||
|
"""Build the public webhook URL for MusicGPT async callbacks."""
|
||||||
|
base = current_app.config.get('PUBLIC_BASE_URL', '').rstrip('/')
|
||||||
|
return f"{base}/api/musicgpt/webhook"
|
||||||
|
|
||||||
|
|
||||||
def get_hermes_api_key():
|
def get_hermes_api_key():
|
||||||
"""Return the effective Hermes API key."""
|
"""Return the effective Hermes API key."""
|
||||||
env_key = current_app.config.get('HERMES_API_KEY', '')
|
env_key = current_app.config.get('HERMES_API_KEY', '')
|
||||||
|
|
@ -500,3 +525,202 @@ def get_booth_open():
|
||||||
"""Return True if the booth is currently marked as open in runtime settings."""
|
"""Return True if the booth is currently marked as open in runtime settings."""
|
||||||
cfg = load_booth_settings()
|
cfg = load_booth_settings()
|
||||||
return cfg.get('booth_open', True)
|
return cfg.get('booth_open', True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MusicGPT API client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MUSICGPT_API_BASE = "https://api.musicgpt.com/api/public"
|
||||||
|
|
||||||
|
|
||||||
|
def _musicgpt_headers():
|
||||||
|
"""Return authorization headers for MusicGPT API calls."""
|
||||||
|
return {
|
||||||
|
"Authorization": get_musicgpt_api_key(),
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def musicgpt_generate_request(rid, title, music_style, lyrics, gender=None, model=None):
|
||||||
|
"""
|
||||||
|
Queue a Music AI v2 generation request.
|
||||||
|
Returns (task_id, conversion_id_1, conversion_id_2, credit_estimate, error_message).
|
||||||
|
"""
|
||||||
|
url = f"{MUSICGPT_API_BASE}/v2/MusicAI"
|
||||||
|
if model is None:
|
||||||
|
model = get_musicgpt_default_model()
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"music_style": music_style,
|
||||||
|
"lyrics": lyrics,
|
||||||
|
"make_instrumental": False,
|
||||||
|
"model": model,
|
||||||
|
"webhook_url": build_musicgpt_webhook_url(),
|
||||||
|
}
|
||||||
|
if gender and gender.lower() in ("male", "female", "neutral"):
|
||||||
|
payload["gender"] = gender.lower()
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, headers=_musicgpt_headers(), timeout=30)
|
||||||
|
data = resp.json()
|
||||||
|
if resp.status_code == 200 and data.get("success"):
|
||||||
|
return (
|
||||||
|
data.get("task_id"),
|
||||||
|
data.get("conversion_id_1"),
|
||||||
|
data.get("conversion_id_2"),
|
||||||
|
data.get("credit_estimate"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
return None, None, None, None, data.get("error") or f"HTTP {resp.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
return None, None, None, None, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def musicgpt_poll_status(task_id):
|
||||||
|
"""
|
||||||
|
Poll the MusicGPT API for a generation task status.
|
||||||
|
Returns a dict with keys: status, message, conversion, or error.
|
||||||
|
"""
|
||||||
|
url = f"{MUSICGPT_API_BASE}/v1/byId"
|
||||||
|
params = {"conversionType": "MUSIC_AI", "task_id": task_id}
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, headers={"Authorization": get_musicgpt_api_key()}, params=params, timeout=20)
|
||||||
|
data = resp.json()
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return data
|
||||||
|
return {"error": data.get("error") or f"HTTP {resp.status_code}"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def musicgpt_queue_stems(rid, audio_url, stems=None):
|
||||||
|
"""
|
||||||
|
Queue a stem extraction job for a generated audio URL.
|
||||||
|
Returns (task_id, conversion_id, credit_estimate, error_message).
|
||||||
|
"""
|
||||||
|
url = f"{MUSICGPT_API_BASE}/v2/Extraction"
|
||||||
|
if stems is None:
|
||||||
|
stems = ["vocals", "instrumental"]
|
||||||
|
payload = {
|
||||||
|
"audio_url": audio_url,
|
||||||
|
"stems": json.dumps(stems),
|
||||||
|
"webhook_url": build_musicgpt_webhook_url(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, data=payload, headers={"Authorization": get_musicgpt_api_key()}, timeout=30)
|
||||||
|
data = resp.json()
|
||||||
|
if resp.status_code == 200 and data.get("success"):
|
||||||
|
return data.get("task_id"), data.get("conversion_id"), data.get("credit_estimate"), None
|
||||||
|
return None, None, None, data.get("error") or f"HTTP {resp.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
return None, None, None, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_file(url, dest):
|
||||||
|
"""Download a file from url to dest. Returns True on success."""
|
||||||
|
try:
|
||||||
|
with requests.get(url, stream=True, timeout=120) as r:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
for chunk in r.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def download_musicgpt_outputs(req, data):
|
||||||
|
"""
|
||||||
|
Download the MP3 and WAV outputs from a completed MusicGPT webhook/poll payload.
|
||||||
|
Updates the request row with local paths and returns a dict of saved paths.
|
||||||
|
`data` is the conversion dict from the API (may contain conversion_path_1/2 keys).
|
||||||
|
"""
|
||||||
|
request_id = req["id"]
|
||||||
|
upload_dir = upload_path(request_id)
|
||||||
|
song_title = req.get("suno_title") or req.get("title")
|
||||||
|
saved = {}
|
||||||
|
|
||||||
|
def _save(url_key, wav_key, field_mp3, field_wav, version):
|
||||||
|
mp3_url = data.get(url_key)
|
||||||
|
wav_url = data.get(wav_key)
|
||||||
|
if mp3_url:
|
||||||
|
ext = Path(mp3_url).suffix or ".mp3"
|
||||||
|
dest = upload_dir / f"{version}{ext}"
|
||||||
|
if _download_file(mp3_url, dest):
|
||||||
|
apply_mp3_tags(str(dest), song_title)
|
||||||
|
saved[field_mp3] = str(dest)
|
||||||
|
if wav_url:
|
||||||
|
dest_wav = upload_dir / f"{version}.wav"
|
||||||
|
if _download_file(wav_url, dest_wav):
|
||||||
|
saved[field_wav] = str(dest_wav)
|
||||||
|
|
||||||
|
_save("conversion_path_1", "conversion_path_wav_1", "song_a_path", "song_a_wav_path", "A")
|
||||||
|
_save("conversion_path_2", "conversion_path_wav_2", "song_b_path", "song_b_wav_path", "B")
|
||||||
|
|
||||||
|
# Album cover
|
||||||
|
cover_url = data.get("album_cover_path")
|
||||||
|
if cover_url:
|
||||||
|
saved["album_cover_url"] = cover_url
|
||||||
|
|
||||||
|
# Cost
|
||||||
|
cost = data.get("conversion_cost")
|
||||||
|
if cost is None:
|
||||||
|
# Webhook sample sometimes has conversion_cost string; poll has it nested.
|
||||||
|
cost = data.get("conversion_cost")
|
||||||
|
try:
|
||||||
|
saved["musicgpt_cost"] = float(cost) if cost is not None else None
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
saved["musicgpt_cost"] = None
|
||||||
|
|
||||||
|
if saved:
|
||||||
|
update_request(request_id, **saved)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def format_musicgpt_cost(cost):
|
||||||
|
"""Return a human-readable cost string in USD."""
|
||||||
|
if cost is None:
|
||||||
|
return "—"
|
||||||
|
return f"${float(cost):.4f} USD"
|
||||||
|
|
||||||
|
|
||||||
|
def download_album_cover(rid, cover_url):
|
||||||
|
"""Download a MusicGPT album cover to a temp file and return its path."""
|
||||||
|
if not cover_url:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
import tempfile
|
||||||
|
resp = requests.get(cover_url, timeout=30)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
ext = Path(cover_url).suffix or '.jpg'
|
||||||
|
tmp = Path(tempfile.gettempdir()) / f"cover_{rid}{ext}"
|
||||||
|
tmp.write_bytes(resp.content)
|
||||||
|
return str(tmp)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_musicgpt_cost_totals():
|
||||||
|
"""Return aggregate MusicGPT cost metrics across all requests."""
|
||||||
|
from models import get_db
|
||||||
|
db = get_db()
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS song_count,
|
||||||
|
COALESCE(SUM(musicgpt_cost), 0) AS total_cost,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN song_a_path IS NOT NULL AND song_b_path IS NOT NULL THEN 1 ELSE 0
|
||||||
|
END), 0) AS completed_pairs,
|
||||||
|
COALESCE(SUM(stems_cost), 0) AS stems_total
|
||||||
|
FROM requests
|
||||||
|
WHERE musicgpt_cost IS NOT NULL
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return {
|
||||||
|
"song_count": rows["song_count"] or 0,
|
||||||
|
"completed_pairs": rows["completed_pairs"] or 0,
|
||||||
|
"total_cost": rows["total_cost"] or 0,
|
||||||
|
"stems_total": rows["stems_total"] or 0,
|
||||||
|
}
|
||||||
|
|
|
||||||
20
models.py
20
models.py
|
|
@ -53,7 +53,20 @@ CREATE TABLE IF NOT EXISTS requests (
|
||||||
revision_note TEXT,
|
revision_note TEXT,
|
||||||
operator_notes TEXT,
|
operator_notes TEXT,
|
||||||
stems_link TEXT,
|
stems_link TEXT,
|
||||||
stems_interest INTEGER DEFAULT 0
|
stems_interest INTEGER DEFAULT 0,
|
||||||
|
musicgpt_task_id TEXT,
|
||||||
|
musicgpt_status TEXT,
|
||||||
|
musicgpt_cost REAL,
|
||||||
|
musicgpt_error TEXT,
|
||||||
|
album_cover_url TEXT,
|
||||||
|
song_a_wav_path TEXT,
|
||||||
|
song_b_wav_path TEXT,
|
||||||
|
deliver_wav INTEGER DEFAULT 0,
|
||||||
|
stems_task_id TEXT,
|
||||||
|
stems_status TEXT,
|
||||||
|
stems_cost REAL,
|
||||||
|
stems_url TEXT,
|
||||||
|
stems_error TEXT
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
|
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
|
||||||
|
|
@ -108,7 +121,10 @@ def init_db():
|
||||||
'customer_approved', 'approval_notified_at', 'preview_sent_at',
|
'customer_approved', 'approval_notified_at', 'preview_sent_at',
|
||||||
'delivery_sent_at', 'square_payment_ref', 'admin_alert_email',
|
'delivery_sent_at', 'square_payment_ref', 'admin_alert_email',
|
||||||
'player_token', 'revision_count', 'revision_note', 'operator_notes',
|
'player_token', 'revision_count', 'revision_note', 'operator_notes',
|
||||||
'stems_link', 'stems_interest'
|
'stems_link', 'stems_interest', 'musicgpt_task_id', 'musicgpt_status',
|
||||||
|
'musicgpt_cost', 'musicgpt_error', 'album_cover_url', 'song_a_wav_path',
|
||||||
|
'song_b_wav_path', 'deliver_wav', 'stems_task_id', 'stems_status',
|
||||||
|
'stems_cost', 'stems_url', 'stems_error'
|
||||||
],
|
],
|
||||||
'revision_history': [
|
'revision_history': [
|
||||||
'id', 'created_at', 'request_id', 'revision_count', 'note',
|
'id', 'created_at', 'request_id', 'revision_count', 'note',
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,9 @@
|
||||||
<a href="{{ url_for('admin_dashboard', status='prompt_ready') }}" class="{% if current_status == 'prompt_ready' %}active{% endif %}">Needs Upload</a>
|
<a href="{{ url_for('admin_dashboard', status='prompt_ready') }}" class="{% if current_status == 'prompt_ready' %}active{% endif %}">Needs Upload</a>
|
||||||
<a href="{{ url_for('admin_dashboard', status='awaiting_payment') }}" class="{% if current_status == 'awaiting_payment' %}active{% endif %}">Awaiting Payment</a>
|
<a href="{{ url_for('admin_dashboard', status='awaiting_payment') }}" class="{% if current_status == 'awaiting_payment' %}active{% endif %}">Awaiting Payment</a>
|
||||||
<a href="{{ url_for('admin_dashboard', status='delivered') }}" class="{% if current_status == 'delivered' %}active{% endif %}">Delivered</a>
|
<a href="{{ url_for('admin_dashboard', status='delivered') }}" class="{% if current_status == 'delivered' %}active{% endif %}">Delivered</a>
|
||||||
|
<form method="POST" action="{{ url_for('admin_musicgpt_refresh') }}" style="display:inline;margin-left:auto">
|
||||||
|
<button type="submit" class="secondary" style="margin:0;padding:.35rem .7rem;font-weight:600;background:#4b5563">Refresh MusicGPT Status</button>
|
||||||
|
</form>
|
||||||
<span class="refresh-hint">
|
<span class="refresh-hint">
|
||||||
{% if refresh_seconds and refresh_seconds > 0 %}
|
{% if refresh_seconds and refresh_seconds > 0 %}
|
||||||
Auto-refresh every {{ refresh_seconds }}s
|
Auto-refresh every {{ refresh_seconds }}s
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,29 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="section" style="background:#1f2937;padding:1rem;border-radius:.5rem;margin-bottom:1rem">
|
||||||
|
<h2 style="margin-top:0">MusicGPT Costs (USD)</h2>
|
||||||
|
<div class="price-row">
|
||||||
|
<div>
|
||||||
|
<p class="hint" style="margin:0">Songs generated</p>
|
||||||
|
<p style="font-size:1.25rem;font-weight:700;margin:.25rem 0">{{ cost_totals.song_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="hint" style="margin:0">Completed A/B pairs</p>
|
||||||
|
<p style="font-size:1.25rem;font-weight:700;margin:.25rem 0">{{ cost_totals.completed_pairs }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="hint" style="margin:0">Music cost total</p>
|
||||||
|
<p style="font-size:1.25rem;font-weight:700;margin:.25rem 0">${{ "%.4f"|format(cost_totals.total_cost) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="hint" style="margin:0">Stems cost total</p>
|
||||||
|
<p style="font-size:1.25rem;font-weight:700;margin:.25rem 0">${{ "%.4f"|format(cost_totals.stems_total) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-top:.5rem">Per-song cost approx. $0.035–$0.06 USD on Pro plan depending on model.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<h2>Standard Items</h2>
|
<h2>Standard Items</h2>
|
||||||
<p class="hint">Leave blank to hide a price. Enter numbers only or include currency symbols as you prefer.</p>
|
<p class="hint">Leave blank to hide a price. Enter numbers only or include currency symbols as you prefer.</p>
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,11 @@
|
||||||
<h1>Request #{{ req.id }} — {{ req.name }}</h1>
|
<h1>Request #{{ req.id }} — {{ req.name }}</h1>
|
||||||
<div class="status-row">
|
<div class="status-row">
|
||||||
<p style="margin:0"><strong>Status:</strong> <span class="status-badge">{{ statuses[req.status] }}</span></p>
|
<p style="margin:0"><strong>Status:</strong> <span class="status-badge">{{ statuses[req.status] }}</span></p>
|
||||||
|
{% if req.musicgpt_status %}
|
||||||
|
<p style="margin:0"><strong>MusicGPT:</strong> <span class="status-badge {% if req.musicgpt_status == 'COMPLETED' %}ok{% elif req.musicgpt_status in ('FAILED','ERROR','CANCELLED') %}missing{% endif %}">{{ req.musicgpt_status }}</span>
|
||||||
|
{% if req.musicgpt_cost is not none %}({{ format_musicgpt_cost(req.musicgpt_cost) }}){% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
{% if req.status != 'cancelled' %}
|
{% if req.status != 'cancelled' %}
|
||||||
<form method="POST" onsubmit="return confirm('Are you sure you want to cancel request #{{ req.id }}?')" style="margin:0">
|
<form method="POST" onsubmit="return confirm('Are you sure you want to cancel request #{{ req.id }}?')" style="margin:0">
|
||||||
<input type="hidden" name="action" value="cancel_request">
|
<input type="hidden" name="action" value="cancel_request">
|
||||||
|
|
@ -172,6 +177,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if req.album_cover_url %}
|
||||||
|
<div class="section" style="text-align:center">
|
||||||
|
<img src="{{ req.album_cover_url }}" alt="Album cover" style="max-width:320px;border-radius:.5rem">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
<div class="flash {{ category }}">{{ message }}</div>
|
<div class="flash {{ category }}">{{ message }}</div>
|
||||||
|
|
@ -322,11 +333,15 @@
|
||||||
|
|
||||||
<!-- RIGHT COLUMN -->
|
<!-- RIGHT COLUMN -->
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<!-- Generate Suno Prompt -->
|
<!-- Generate Music Prompt -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>1. Generate & Save Suno 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>
|
||||||
<p class="copy-hint">Paste Hermes' Title, Style, and Lyrics directly into the fields below, then save.</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 %}
|
||||||
|
<div class="flash error">Error: {{ req.musicgpt_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="action" value="save_prompt">
|
<input type="hidden" name="action" value="save_prompt">
|
||||||
|
|
@ -343,32 +358,67 @@
|
||||||
<button type="submit">Save Prompt</button>
|
<button type="submit">Save Prompt</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<hr style="border-color:#374151;margin:1rem 0">
|
||||||
|
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="generate_musicgpt">
|
||||||
|
<input type="hidden" name="suno_title" value="{{ req.suno_title or '' }}">
|
||||||
|
<input type="hidden" name="suno_style" value="{{ req.suno_style or '' }}">
|
||||||
|
<input type="hidden" name="suno_lyrics" value="{{ req.suno_lyrics or '' }}">
|
||||||
|
|
||||||
|
<label for="musicgpt_model">MusicGPT Model</label>
|
||||||
|
<select id="musicgpt_model" name="musicgpt_model">
|
||||||
|
{% for m in musicgpt_models %}
|
||||||
|
<option value="{{ m }}" {% if m == musicgpt_default_model %}selected{% endif %}>{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="deliver_wav_gen" style="display:flex;align-items:center;gap:.5rem;margin-top:1rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="deliver_wav_gen" name="deliver_wav" value="1" {% if req.deliver_wav %}checked{% endif %} style="width:auto;margin:0">
|
||||||
|
Include WAV files with delivery
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{% set in_progress = req.musicgpt_status in ('IN_QUEUE', 'IN_PROGRESS') %}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" {% if in_progress %}disabled{% endif %}>Generate A/B with MusicGPT</button>
|
||||||
|
{% if in_progress %}
|
||||||
|
<button type="submit" name="action" value="cancel_musicgpt" class="danger">Cancel Generation</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Songs -->
|
<!-- Upload Songs -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>2. Upload Songs</h2>
|
<h2>2. Songs</h2>
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
<input type="hidden" name="action" value="delete_songs">
|
<input type="hidden" name="action" value="delete_songs">
|
||||||
<div class="song-row">
|
<div class="song-row">
|
||||||
<input type="checkbox" id="delete_a" name="delete_song" value="a"
|
<input type="checkbox" id="delete_a" name="delete_song" value="a"
|
||||||
{% if not (req.song_a_path and file_exists(req.song_a_path)) %}disabled{% endif %}>
|
{% if not (req.song_a_path and file_exists(req.song_a_path)) %}disabled{% endif %}>
|
||||||
<label for="delete_a">Version A:
|
<label for="delete_a">Version A MP3:
|
||||||
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
||||||
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_a_path) }}
|
<span class="status-badge ok">✅ Ready</span> {{ basename(req.song_a_path) }}
|
||||||
|
{% if req.song_a_wav_path and file_exists(req.song_a_wav_path) %}
|
||||||
|
<br><span class="status-badge ok">WAV</span> {{ basename(req.song_a_wav_path) }}
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="status-badge missing">❌ Not uploaded</span>
|
<span class="status-badge missing">❌ Not ready</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="song-row">
|
<div class="song-row">
|
||||||
<input type="checkbox" id="delete_b" name="delete_song" value="b"
|
<input type="checkbox" id="delete_b" name="delete_song" value="b"
|
||||||
{% if not (req.song_b_path and file_exists(req.song_b_path)) %}disabled{% endif %}>
|
{% if not (req.song_b_path and file_exists(req.song_b_path)) %}disabled{% endif %}>
|
||||||
<label for="delete_b">Version B:
|
<label for="delete_b">Version B MP3:
|
||||||
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
||||||
<span class="status-badge ok">✅ Uploaded</span> {{ basename(req.song_b_path) }}
|
<span class="status-badge ok">✅ Ready</span> {{ basename(req.song_b_path) }}
|
||||||
|
{% if req.song_b_wav_path and file_exists(req.song_b_wav_path) %}
|
||||||
|
<br><span class="status-badge ok">WAV</span> {{ basename(req.song_b_wav_path) }}
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="status-badge missing">❌ Not uploaded</span>
|
<span class="status-badge missing">❌ Not ready</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -381,14 +431,45 @@
|
||||||
|
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="action" value="upload_songs">
|
<input type="hidden" name="action" value="upload_songs">
|
||||||
<label for="song_a">Version A MP3</label>
|
<label for="song_a">Version A MP3 (override)</label>
|
||||||
<input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
|
<input type="file" id="song_a" name="song_a" accept="audio/mpeg,.mp3">
|
||||||
<label for="song_b">Version B MP3</label>
|
<label for="song_b">Version B MP3 (override)</label>
|
||||||
<input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
|
<input type="file" id="song_b" name="song_b" accept="audio/mpeg,.mp3">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="submit">Upload Songs</button>
|
<button type="submit">Upload Songs</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<hr style="border-color:#374151;margin:1rem 0">
|
||||||
|
|
||||||
|
<!-- Stems -->
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="generate_stems">
|
||||||
|
<label for="stems_source_url">Stems Source Audio URL</label>
|
||||||
|
<input type="url" id="stems_source_url" name="stems_source_url" placeholder="https://.../song.mp3" value="{{ req.song_a_path or '' }}">
|
||||||
|
<p class="copy-hint">Vocal + instrumental extraction: ~$0.018–$0.084 USD per song on Pro plan.</p>
|
||||||
|
|
||||||
|
<label for="stems_status">Stems Status:</label>
|
||||||
|
<p>
|
||||||
|
{% if req.stems_status %}
|
||||||
|
<span class="status-badge {% if req.stems_status == 'COMPLETED' %}ok{% elif req.stems_status in ('FAILED','ERROR') %}missing{% endif %}">{{ req.stems_status }}</span>
|
||||||
|
{% if req.stems_cost is not none %}({{ format_musicgpt_cost(req.stems_cost) }}){% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span class="status-badge missing">Not requested</span>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if req.stems_error %}
|
||||||
|
<div class="flash error">{{ req.stems_error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if req.stems_url %}
|
||||||
|
<p><a href="{{ req.stems_url }}" target="_blank">Download stems →</a></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set stems_in_progress = req.stems_status in ('IN_QUEUE', 'IN_PROGRESS') %}
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" {% if stems_in_progress %}disabled{% endif %}>Generate Stems</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notify Customer -->
|
<!-- Notify Customer -->
|
||||||
|
|
@ -449,6 +530,20 @@
|
||||||
|
|
||||||
<hr style="border-color:#374151;margin:1rem 0">
|
<hr style="border-color:#374151;margin:1rem 0">
|
||||||
|
|
||||||
|
<p style="color:#93c5fd;font-weight:600">Delivery options:</p>
|
||||||
|
<form method="POST" style="margin-bottom:.5rem">
|
||||||
|
<input type="hidden" name="action" value="save_delivery_options">
|
||||||
|
<label for="deliver_wav" style="display:flex;align-items:center;gap:.5rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="deliver_wav" name="deliver_wav" value="1" {% if req.deliver_wav %}checked{% endif %} style="width:auto;margin:0">
|
||||||
|
Include WAV files in delivery email
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit" class="secondary">Save Delivery Options</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr style="border-color:#374151;margin:1rem 0">
|
||||||
|
|
||||||
<p style="color:#93c5fd;font-weight:600">Select files to deliver:</p>
|
<p style="color:#93c5fd;font-weight:600">Select files to deliver:</p>
|
||||||
{% set all_files = [] %}
|
{% set all_files = [] %}
|
||||||
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
{% if req.song_a_path and file_exists(req.song_a_path) %}
|
||||||
|
|
@ -457,6 +552,14 @@
|
||||||
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
{% if req.song_b_path and file_exists(req.song_b_path) %}
|
||||||
{% set _ = all_files.append(req.song_b_path) %}
|
{% set _ = all_files.append(req.song_b_path) %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if req.deliver_wav %}
|
||||||
|
{% if req.song_a_wav_path and file_exists(req.song_a_wav_path) %}
|
||||||
|
{% set _ = all_files.append(req.song_a_wav_path) %}
|
||||||
|
{% endif %}
|
||||||
|
{% if req.song_b_wav_path and file_exists(req.song_b_wav_path) %}
|
||||||
|
{% set _ = all_files.append(req.song_b_wav_path) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% set files_to_show = all_files + extra_files %}
|
{% set files_to_show = all_files + extra_files %}
|
||||||
{% for fpath in files_to_show %}
|
{% for fpath in files_to_show %}
|
||||||
|
|
@ -505,7 +608,7 @@
|
||||||
extra_requests: {{ req.extra_requests | tojson }},
|
extra_requests: {{ req.extra_requests | tojson }},
|
||||||
stems_interest: {{ (req.stems_interest or 0) | tojson }}
|
stems_interest: {{ (req.stems_interest or 0) | tojson }}
|
||||||
};
|
};
|
||||||
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
|
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate an AI music prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nExpected JSON response format:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
|
||||||
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL."));
|
navigator.clipboard.writeText(text).then(() => alert("Copied! Paste into Hermes. Hermes will POST the generated prompt back to the Callback URL."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -541,7 +644,7 @@
|
||||||
extra_requests: {{ req.extra_requests | tojson }},
|
extra_requests: {{ req.extra_requests | tojson }},
|
||||||
stems_interest: {{ (req.stems_interest or 0) | tojson }}
|
stems_interest: {{ (req.stems_interest or 0) | tojson }}
|
||||||
};
|
};
|
||||||
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
|
const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW AI music prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\nInterested in STEMS: ${data.stems_interest ? 'Yes' : 'No'}\\n\\nPreviously generated prompt (do not copy verbatim; adapt and improve):\\nTitle: ${currentTitle || '-'}\\nStyle: ${currentStyle || '-'}\\nLyrics:\\n${currentLyrics || '-'}\\n\\nRevision request from customer:\\n${revisionNote || '-'}\\n\\nPOST the new prompt back to the Callback URL as JSON with this shape:\\n{\\n \\"request_id\\": ${data.request_id},\\n \\"email\\": \\"${data.email}\\",\\n \\"suno_title\\": \\"...\\",\\n \\"suno_style\\": \\"...\\",\\n \\"suno_lyrics\\": \\"...\\"\\n}`;
|
||||||
navigator.clipboard.writeText(text).then(() => alert("Revision prompt copied! Paste into Hermes. Hermes will POST the revised prompt back to the Callback URL."));
|
navigator.clipboard.writeText(text).then(() => alert("Revision prompt copied! Paste into Hermes. Hermes will POST the revised prompt back to the Callback URL."));
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,28 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MusicGPT integration -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>MusicGPT Integration</h2>
|
||||||
|
<p class="copy-hint">API key is set via the <code>MUSICGPT_API_KEY</code> environment variable in Docker.</p>
|
||||||
|
<p><strong>API key configured:</strong> {{ '✅ Yes' if musicgpt_api_key_set else '❌ No' }}</p>
|
||||||
|
<p><strong>Webhook URL:</strong> <span class="path">{{ musicgpt_webhook_url }}</span></p>
|
||||||
|
<p class="copy-hint">Register this URL in your MusicGPT account webhook settings so completed songs POST back here.</p>
|
||||||
|
|
||||||
|
<hr style="border:none;border-top:1px solid var(--border);margin:1.5rem 0;">
|
||||||
|
|
||||||
|
<h3>Album Cover Delivery</h3>
|
||||||
|
<p class="copy-hint">When enabled, the generated album cover is attached to the delivery email. It is always shown on this admin page.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_album_cover">
|
||||||
|
<label for="deliver_album_cover" style="display:flex;align-items:center;gap:.5rem;cursor:pointer;">
|
||||||
|
<input type="checkbox" id="deliver_album_cover" name="deliver_album_cover" value="1" {% if deliver_album_cover %}checked{% endif %} style="width:auto;margin:0">
|
||||||
|
Attach album cover to delivery email
|
||||||
|
</label>
|
||||||
|
<button type="submit">Save Album Cover Setting</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Email / SMTP configuration -->
|
<!-- Email / SMTP configuration -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Email (SMTP) Settings</h2>
|
<h2>Email (SMTP) Settings</h2>
|
||||||
|
|
|
||||||
|
|
@ -62,13 +62,13 @@
|
||||||
<p>We create a custom, AI-generated theme song just for you based on your personality, hobbies, and style preferences.</p>
|
<p>We create a custom, AI-generated theme song just for you based on your personality, hobbies, and style preferences.</p>
|
||||||
|
|
||||||
<p><strong>How long does it take to get my song?</strong></p>
|
<p><strong>How long does it take to get my song?</strong></p>
|
||||||
<p>Most songs are ready to preview within a few minutes to an hour, depending on how busy the booth is!</p>
|
<p>Most songs are ready to preview within a few minutes, depending on how busy the booth is!</p>
|
||||||
|
|
||||||
<p><strong>How much does it cost?</strong></p>
|
<p><strong>How much does it cost?</strong></p>
|
||||||
<p>Pricing is displayed at the booth; you pay after listening to and choosing the version you want.</p>
|
<p>Pricing is displayed at the booth; you pay after listening to and choosing the version you want.</p>
|
||||||
|
|
||||||
<p><strong>What do I actually receive?</strong></p>
|
<p><strong>What do I actually receive?</strong></p>
|
||||||
<p>You get an MP3 file of the version you selected, delivered straight to your email. WAV format is available for a slight additional charge.</p>
|
<p>You get MP3 files of the version(s) you selected, delivered straight to your email. WAV format and STEMS are available for an additional charge.</p>
|
||||||
|
|
||||||
<h2>How it works</h2>
|
<h2>How it works</h2>
|
||||||
<p><strong>What info do I need to give you?</strong></p>
|
<p><strong>What info do I need to give you?</strong></p>
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
<p>To the email address you gave us, so double-check it for typos before submitting.</p>
|
<p>To the email address you gave us, so double-check it for typos before submitting.</p>
|
||||||
|
|
||||||
<p><strong>What file format is it?</strong></p>
|
<p><strong>What file format is it?</strong></p>
|
||||||
<p>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.</p>
|
<p>The delivered song is a standard MP3 file that plays on virtually any device. WAV files and STEMS can be provided upon request.</p>
|
||||||
|
|
||||||
<p><strong>Can you send it to someone else's email?</strong></p>
|
<p><strong>Can you send it to someone else's email?</strong></p>
|
||||||
<p>At the booth we can update the email address if needed, but the original request needs a valid email to start.</p>
|
<p>At the booth we can update the email address if needed, but the original request needs a valid email to start.</p>
|
||||||
|
|
|
||||||
|
|
@ -128,6 +128,13 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Album cover -->
|
||||||
|
{% if req.album_cover_url %}
|
||||||
|
<div style="text-align:center;margin:1rem 0">
|
||||||
|
<img src="{{ req.album_cover_url }}" alt="Album cover" style="max-width:320px;border-radius:.5rem">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Version A player -->
|
<!-- Version A player -->
|
||||||
<div class="player">
|
<div class="player">
|
||||||
<h3>Version A</h3>
|
<h3>Version A</h3>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue