Integrate MusicGPT API generation into booth-musicgpt v0.7.0

This commit is contained in:
Troll (Hermes Agent) 2026-08-10 22:01:08 +00:00
parent 99855b1212
commit 2ca148be9b
12 changed files with 721 additions and 44 deletions

270
app.py
View file

@ -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/<int:rid>')
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(),
)