diff --git a/app.py b/app.py index 4cc688f..cb0c975 100644 --- a/app.py +++ b/app.py @@ -66,6 +66,7 @@ from helpers import ( musicgpt_generate_request, musicgpt_queue_stems, musicgpt_poll_status, download_musicgpt_outputs, format_musicgpt_cost, get_musicgpt_cost_totals, download_album_cover, + process_stems_to_gokapi, get_ntfy_config, send_ntfy, sign_prompt_callback, verify_prompt_callback, build_prompt_callback_url, send_email, build_signature_images, @@ -419,7 +420,6 @@ def musicgpt_webhook(): 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: sc = float(data.get('conversion_cost') or 0) except (ValueError, TypeError): @@ -429,6 +429,16 @@ def musicgpt_webhook(): 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) + + # After stems are complete, upload them to Gokapi for a shareable link. + if new_status in ('COMPLETED', 'FINISHED') and Config.GOKAPI_URL and Config.GOKAPI_API_KEY: + row = db.execute('SELECT * FROM requests WHERE id = ?', (req['id'],)).fetchone() + req = dict(row) + gokapi_url, gokapi_err = process_stems_to_gokapi(req) + if gokapi_err: + app.logger.warning(f'Gokapi upload failed for request {req["id"]}: {gokapi_err}') + # Note: process_stems_to_gokapi already saves stems_link to the DB on success. + return jsonify({'ok': True, 'request_id': req['id']}), 200 return jsonify({'ok': False, 'reason': 'unknown_conversion_type'}), 400 diff --git a/config.py b/config.py index e457d2a..94b1f93 100644 --- a/config.py +++ b/config.py @@ -110,6 +110,11 @@ class Config: MUSICGPT_MODELS = ['v6', 'v6-pro'] MUSICGPT_DEFAULT_MODEL = os.environ.get('MUSICGPT_DEFAULT_MODEL', 'v6-pro') + # Gokapi file-sharing server for stems uploads. + GOKAPI_URL = os.environ.get('GOKAPI_URL', '') + GOKAPI_API_KEY = os.environ.get('GOKAPI_API_KEY', '') + GOKAPI_EXPIRY_DAYS = int(os.environ.get('GOKAPI_EXPIRY_DAYS', '30')) + @classmethod def musicgpt_webhook_base_url(cls): """Return the public base URL used for MusicGPT webhooks.""" diff --git a/docker-compose.yml b/docker-compose.yml index 13f7b09..7bb30be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,8 @@ # Required: APP_SECRET_KEY, ADMIN_PASSWORD, SMTP_PASS, PUBLIC_BASE_URL, MUSICGPT_API_KEY # Optional: BOOTH_NAME, HOST_PORT, INTERNAL_PORT, PRICE_PER_VERSION, # CURRENCY, MAX_REVISIONS, HERMES_API_KEY, DATABASE, UPLOAD_FOLDER, -# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM +# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_FROM, +# GOKAPI_URL, GOKAPI_API_KEY, GOKAPI_EXPIRY_DAYS # # Named volumes keep the SQLite database and uploaded/downloaded songs persistent # across container restarts and redeploys. @@ -40,6 +41,9 @@ services: - CURRENCY=${CURRENCY:-CAD} - DATABASE=${DATABASE:-/app/data/booth.db} - UPLOAD_FOLDER=${UPLOAD_FOLDER:-/app/uploads} + - GOKAPI_URL=${GOKAPI_URL:-} + - GOKAPI_API_KEY=${GOKAPI_API_KEY:-} + - GOKAPI_EXPIRY_DAYS=${GOKAPI_EXPIRY_DAYS:-30} ports: - "${HOST_PORT:-0.0.0.0:8500}:${INTERNAL_PORT:-8000}" volumes: diff --git a/helpers.py b/helpers.py index a502198..8d902b7 100644 --- a/helpers.py +++ b/helpers.py @@ -34,6 +34,7 @@ from mutagen.easyid3 import EasyID3 import requests from models import update_request +from config import Config # --------------------------------------------------------------------------- @@ -794,3 +795,116 @@ def get_musicgpt_cost_totals(): "total_cost": rows["total_cost"] or 0, "stems_total": rows["stems_total"] or 0, } + + +def parse_stems_urls(stems_url_field): + """ + Parse the stems_url DB field into a list of (label, url) tuples. + The field can be: + - "label1: url1; label2: url2" (multiple individual stem files) + - A single bundle/zip URL + - A bare URL + """ + if not stems_url_field: + return [] + urls = [] + if ';' in stems_url_field or ': ' in stems_url_field: + parts = stems_url_field.split(';') + for part in parts: + part = part.strip() + if not part: + continue + if ': ' in part: + label, url = part.split(': ', 1) + urls.append((label.strip(), url.strip())) + else: + urls.append(('stem', part)) + else: + urls.append(('stems', stems_url_field.strip())) + return urls + + +def upload_to_gokapi(file_bytes, filename, expiry_days=30): + """ + Upload a file to Gokapi and return the download URL. + Returns (download_url, error_message). + """ + gokapi_url = Config.GOKAPI_URL.rstrip('/') + api_key = Config.GOKAPI_API_KEY + if not gokapi_url or not api_key: + return None, 'Gokapi URL or API key not configured' + try: + resp = requests.post( + f"{gokapi_url}/api/files/add", + headers={"apikey": api_key}, + files={"file": (filename, file_bytes, "application/octet-stream")}, + data={ + "allowedDownloads": "0", + "expiryDays": str(expiry_days), + "password": "", + }, + timeout=120, + ) + if resp.status_code != 200: + return None, f'Gokapi HTTP {resp.status_code}: {resp.text[:200]}' + data = resp.json() + if data.get('Result') != 'OK': + return None, f"Gokapi error: {data.get('Result')}" + file_info = data.get('FileInfo', {}) + download_url = file_info.get('UrlDownload') + if not download_url: + return None, 'Gokapi returned no download URL' + return download_url, None + except Exception as e: + return None, str(e) + + +def process_stems_to_gokapi(req): + """ + Download stem files from MusicGPT CDN, zip them, upload the zip to Gokapi, + and return (gokapi_url, error_message). + + Also updates the request row with the Gokapi download link in stems_link. + """ + import io + import zipfile + + stems_url = req.get('stems_url') or '' + urls = parse_stems_urls(stems_url) + if not urls: + return None, 'No stems URL to process' + + # If there's only one URL and it's already a .zip, upload it directly. + if len(urls) == 1 and urls[0][1].endswith('.zip'): + try: + r = requests.get(urls[0][1], timeout=120) + r.raise_for_status() + zip_bytes = r.content + except Exception as e: + return None, f'Failed to download stems zip: {e}' + else: + # Download each stem file and bundle into a zip in memory. + buf = io.BytesIO() + with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: + for label, url in urls: + try: + r = requests.get(url, timeout=60) + r.raise_for_status() + filename = url.split('/')[-1] if '/' in url else f"{label}.mp3" + if '.' not in filename: + filename = f"{label}.mp3" + zf.writestr(filename, r.content) + except Exception: + pass + zip_bytes = buf.getvalue() + + title = req.get('suno_title') or req.get('name') or f'request_{req["id"]}' + zip_filename = f"stems_{title}.zip" + + download_url, error = upload_to_gokapi(zip_bytes, zip_filename, Config.GOKAPI_EXPIRY_DAYS) + if error: + return None, error + + # Save the Gokapi link to stems_link in the database. + update_request(req['id'], stems_link=download_url) + return download_url, None diff --git a/templates/admin/request.html b/templates/admin/request.html index 771d228..99555d0 100644 --- a/templates/admin/request.html +++ b/templates/admin/request.html @@ -626,7 +626,11 @@ {% if req.stems_error %}
{{ req.stems_error }}
{% endif %} - {% if req.stems_url %} + {% if req.stems_link %} +
+ ⬇ Download Stems +
+ {% elif req.stems_url %}
⬇ Download Stems (zip)