From 2ca148be9bbf06a31bee710890eae52d25eddd70 Mon Sep 17 00:00:00 2001 From: "Troll (Hermes Agent)" Date: Mon, 10 Aug 2026 22:01:08 +0000 Subject: [PATCH] Integrate MusicGPT API generation into booth-musicgpt v0.7.0 --- README.md | 45 +++--- VERSION | 2 +- app.py | 270 ++++++++++++++++++++++++++++++++- config.py | 12 ++ helpers.py | 224 +++++++++++++++++++++++++++ models.py | 20 ++- templates/admin/dashboard.html | 3 + templates/admin/pricing.html | 23 +++ templates/admin/request.html | 131 ++++++++++++++-- templates/admin/settings.html | 22 +++ templates/faq.html | 6 +- templates/player.html | 7 + 12 files changed, 721 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 147bbf8..28b748f 100644 --- a/README.md +++ b/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 1. A visitor fills out a short form at `/request`. -2. The operator reviews the request in the admin dashboard and generates a Suno Custom Mode prompt. -3. The operator (or an AI assistant via the `/api/prompt` callback) saves the prompt to the request. -4. The operator creates two song versions in Suno, downloads them, and uploads **Version A** and **Version B** to the request page. +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 saves the title, style, and lyrics to the request. +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. -6. After the customer approves and pays, the operator records the payment reference and delivers the selected MP3(s) by email. -7. Optional stems / extras can be delivered via a share link that appears on the player page after delivery. +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 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. | | Dashboard | `/admin` | Main queue. Filter by status and auto-refresh at a configurable interval. | -| Request detail | `/admin/request/` | Full control of one request: edit customer info (including pronouns and structured style), save prompt, copy Hermes callback, view revision history, upload MP3s, send preview, record payment, deliver files, add operator notes, and cancel. | -| Pricing | `/admin/pricing` | Configure fixed prices (one song, both songs, WAV per song, STEMs per song) and up to 5 custom items. | +| Request detail | `/admin/request/` | 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 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. | -| 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. | --- @@ -90,9 +92,9 @@ pending → prompt_ready → songs_uploaded → awaiting_payment → paid → de | 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. | -| `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. | | `awaiting_payment` | Customer approved a version; waiting for payment. | | `paid` | Payment recorded. | @@ -162,20 +164,20 @@ Use the output for `APP_SECRET_KEY`. 1. Log in to Portainer. 2. Go to **Stacks** → **Add stack**. 3. Choose **Repository**: - - URL: `https://gitlab.hallsworth.ca/yrtria/theme-song-booth.git` + - URL: `https://gitlab.hallsworth.ca/yrtria/booth-musicgpt.git` - Branch: `main` - Compose path: `docker-compose.yml` 4. Add the environment variables listed in the section below. 5. Deploy the stack. -6. Open a console in the running `theme-song-booth` container and run once: +6. Open a console in the running `booth-musicgpt` container and run once: ```bash python init_db.py ``` 7. Point your reverse proxy at the host port you chose (default `127.0.0.1:8000`). -8. Visit `/admin/settings` and click **Regenerate API Key**. -9. Copy the key to your Hermes skill / AI assistant. +8. Visit `/admin/settings`, confirm the MusicGPT webhook URL, and configure the MusicGPT account webhook endpoint. +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`. ### Updating the deployment @@ -183,7 +185,7 @@ python init_db.py After each push to Gitea: ```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. @@ -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`. | | `CURRENCY` | No | `CAD` | Currency label shown with prices. | | `MAX_REVISIONS` | No | `2` | Default customer revision limit before an operator override. | -| `HERMES_API_KEY` | No | — | API key for the `/api/prompt` callback. If omitted, generate one from `/admin/settings`. | +| `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. | -| `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 ```bash -cd /home/jess/workspace/theme-song-booth +cd /home/jess/workspace/booth-musicgpt python3 -m venv .venv .venv/bin/pip install -r requirements.txt cp .env.example .env diff --git a/VERSION b/VERSION index 844f6a9..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.3 +0.7.0 diff --git a/app.py b/app.py index 8b32217..d092fc2 100644 --- a/app.py +++ b/app.py @@ -33,6 +33,7 @@ import os import hmac import shutil import time +import json from pathlib import Path # 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_max_revisions, get_callback_expiry_hours, 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, sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url, 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 +@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/') def thanks(rid): """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 '', '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') @@ -743,6 +874,94 @@ def admin_request(rid): ) 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': # Remove selected uploaded MP3s and reset status so new songs can be uploaded. to_delete = request.form.getlist('delete_song') @@ -754,20 +973,34 @@ def admin_request(rid): for version in to_delete: if version == 'a': path = req.get('song_a_path') + wav = req.get('song_a_wav_path') if path and Path(path).exists(): try: Path(path).unlink() except OSError: pass + if wav and Path(wav).exists(): + try: + Path(wav).unlink() + except OSError: + pass update_fields['song_a_path'] = None + update_fields['song_a_wav_path'] = None elif version == 'b': path = req.get('song_b_path') + wav = req.get('song_b_wav_path') if path and Path(path).exists(): try: Path(path).unlink() except OSError: pass + if wav and Path(wav).exists(): + try: + Path(wav).unlink() + except OSError: + pass update_fields['song_b_path'] = None + update_fields['song_b_wav_path'] = None if update_fields: # 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(6, "") 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: 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()) @@ -901,7 +1148,11 @@ def admin_request(rid): basename=basename, extra_files=extra_files, 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', 'suno_lyrics', 'song_a_path', 'song_b_path', 'customer_approved', 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', - 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_note', 'revision_count', 'operator_notes', 'stems_link', 'stems_interest' + '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'} 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') 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( 'admin/settings.html', health=health, @@ -1289,6 +1550,9 @@ def admin_settings(): version=current_app.config['VERSION'], current_callback_expiry_hours=get_callback_expiry_hours(), 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(), ) diff --git a/config.py b/config.py index 9aacaf2..002fa0a 100644 --- a/config.py +++ b/config.py @@ -97,3 +97,15 @@ class Config: # Package version, read from VERSION file. 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('/') diff --git a/helpers.py b/helpers.py index d0804eb..d086c8e 100644 --- a/helpers.py +++ b/helpers.py @@ -33,6 +33,8 @@ from mutagen.easyid3 import EasyID3 import requests +from models import update_request + # --------------------------------------------------------------------------- # Genre / decade helpers @@ -334,6 +336,29 @@ def get_callback_expiry_hours(): 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(): """Return the effective 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.""" cfg = load_booth_settings() 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, + } diff --git a/models.py b/models.py index 0baa0b6..ce51c03 100644 --- a/models.py +++ b/models.py @@ -53,7 +53,20 @@ CREATE TABLE IF NOT EXISTS requests ( revision_note TEXT, operator_notes 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); @@ -108,7 +121,10 @@ def init_db(): 'customer_approved', 'approval_notified_at', 'preview_sent_at', 'delivery_sent_at', 'square_payment_ref', 'admin_alert_email', 'player_token', 'revision_count', 'revision_note', 'operator_notes', - 'stems_link', 'stems_interest' + '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': [ 'id', 'created_at', 'request_id', 'revision_count', 'note', diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index c769d4a..ce57a0a 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -134,6 +134,9 @@ Needs Upload Awaiting Payment Delivered +
+ +
{% if refresh_seconds and refresh_seconds > 0 %} Auto-refresh every {{ refresh_seconds }}s diff --git a/templates/admin/pricing.html b/templates/admin/pricing.html index 97dba3b..426da93 100644 --- a/templates/admin/pricing.html +++ b/templates/admin/pricing.html @@ -71,6 +71,29 @@ {% endfor %} {% endwith %} +
+

MusicGPT Costs (USD)

+
+
+

Songs generated

+

{{ cost_totals.song_count }}

+
+
+

Completed A/B pairs

+

{{ cost_totals.completed_pairs }}

+
+
+

Music cost total

+

${{ "%.4f"|format(cost_totals.total_cost) }}

+
+
+

Stems cost total

+

${{ "%.4f"|format(cost_totals.stems_total) }}

+
+
+

Per-song cost approx. $0.035–$0.06 USD on Pro plan depending on model.

+
+

Standard Items

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

diff --git a/templates/admin/request.html b/templates/admin/request.html index 9c3cc79..2c12a64 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -164,6 +164,11 @@

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

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

+ {% if req.musicgpt_status %} +

MusicGPT: {{ req.musicgpt_status }} + {% if req.musicgpt_cost is not none %}({{ format_musicgpt_cost(req.musicgpt_cost) }}){% endif %} +

+ {% endif %} {% if req.status != 'cancelled' %} @@ -172,6 +177,12 @@ {% endif %}
+ {% if req.album_cover_url %} +
+ Album cover +
+ {% endif %} + {% with messages = get_flashed_messages(with_categories=true) %} {% for category, message in messages %}
{{ message }}
@@ -322,11 +333,15 @@
- +
-

1. Generate & Save Suno Prompt

+

1. Generate & Save Music Prompt

-

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

+

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

+ + {% if req.musicgpt_error %} +
Error: {{ req.musicgpt_error }}
+ {% endif %} @@ -343,32 +358,67 @@
+ +
+ +
+ + + + + + + + + + + {% set in_progress = req.musicgpt_status in ('IN_QUEUE', 'IN_PROGRESS') %} +
+ + {% if in_progress %} + + {% endif %} +
+
-

2. Upload Songs

+

2. Songs

-
-
@@ -381,14 +431,45 @@ - + - +
+ +
+ + +
+ + + +

Vocal + instrumental extraction: ~$0.018–$0.084 USD per song on Pro plan.

+ + +

+ {% if req.stems_status %} + {{ req.stems_status }} + {% if req.stems_cost is not none %}({{ format_musicgpt_cost(req.stems_cost) }}){% endif %} + {% else %} + Not requested + {% endif %} +

+ {% if req.stems_error %} +
{{ req.stems_error }}
+ {% endif %} + {% if req.stems_url %} +

Download stems →

+ {% endif %} + + {% set stems_in_progress = req.stems_status in ('IN_QUEUE', 'IN_PROGRESS') %} +
+ +
+
@@ -449,6 +530,20 @@
+

Delivery options:

+
+ + +
+ +
+
+ +
+

Select files to deliver:

{% set all_files = [] %} {% 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) %} {% set _ = all_files.append(req.song_b_path) %} {% 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 %} {% for fpath in files_to_show %} @@ -505,7 +608,7 @@ extra_requests: {{ req.extra_requests | tojson }}, stems_interest: {{ (req.stems_interest or 0) | tojson }} }; - const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nGenerate a Suno Custom Mode prompt for this customer and POST it back to the Callback URL as JSON.\\n\\nCustomer data:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\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.")); } @@ -541,7 +644,7 @@ extra_requests: {{ req.extra_requests | tojson }}, stems_interest: {{ (req.stems_interest or 0) | tojson }} }; - const text = `Booth request: ${data.request_id}\\nCustomer email: ${data.email}\\nCallback URL: ${callbackUrl}\\n\\nThis is REVISION #${revisionCount || 1} for this customer.\\n\\nGenerate a NEW Suno Custom Mode prompt that addresses the following revision request, while keeping the same overall theme/persona and matching the customer's original brief as closely as possible.\\n\\nOriginal brief:\\nName: ${data.name}\\nPronouns: ${data.pronouns || '-'}\\nHobbies: ${data.hobbies || '-'}\\nNotable facts: ${data.notable_facts || '-'}\\nStyle / genre: ${styleSentence || styleGenre || '-'}\\nPreferred singer voice / gender: ${data.vocal_gender || 'No preference'}\\nExtra requests: ${data.extra_requests || '-'}\\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.")); } diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 7f94a79..b4ea104 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -339,6 +339,28 @@ + +
+

MusicGPT Integration

+

API key is set via the MUSICGPT_API_KEY environment variable in Docker.

+

API key configured: {{ '✅ Yes' if musicgpt_api_key_set else '❌ No' }}

+

Webhook URL: {{ musicgpt_webhook_url }}

+

Register this URL in your MusicGPT account webhook settings so completed songs POST back here.

+ +
+ +

Album Cover Delivery

+

When enabled, the generated album cover is attached to the delivery email. It is always shown on this admin page.

+
+ + + +
+
+

Email (SMTP) Settings

diff --git a/templates/faq.html b/templates/faq.html index 8768f28..5f14c35 100644 --- a/templates/faq.html +++ b/templates/faq.html @@ -62,13 +62,13 @@

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

How long does it take to get my song?

-

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

+

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

How much does it cost?

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

What do I actually receive?

-

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

+

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.

How it works

What info do I need to give you?

@@ -137,7 +137,7 @@

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

What file format is it?

-

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

+

The delivered song is a standard MP3 file that plays on virtually any device. WAV files and STEMS can be provided upon request.

Can you send it to someone else's email?

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

diff --git a/templates/player.html b/templates/player.html index 78e1c2a..a2827f4 100644 --- a/templates/player.html +++ b/templates/player.html @@ -128,6 +128,13 @@
{% endif %} + + {% if req.album_cover_url %} +
+ Album cover +
+ {% endif %} +

Version A