From 1135eb24e496dabcc6d21ac8cc6ec6eb9f5b8331 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Tue, 17 Feb 2026 23:46:45 -0500 Subject: [PATCH] add karaoke mode: vocal isolation via unsandbox zerotrust containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-shop unsandbox API keys in shop settings. On upload, audio/video products are processed through voxsplit in a zerotrust container (no network, destroyed after use) producing both instrumentals and vocals tracks. Background backfill with ThreadPoolExecutor sized to account concurrency, exponential backoff retries. Player cycles Original → Instrumentals → Vocals with toggle button or K shortcut. --- make_post_sell/lib/karaoke.py | 292 ++++++++++++++++++ make_post_sell/models/product.py | 2 + make_post_sell/models/shop.py | 4 + ...a1b2c3d4e5f6_add_unsandbox_keys_to_shop.py | 40 +++ make_post_sell/scripts/backfill_karaoke.py | 107 +++++++ make_post_sell/static/js/player.js | 7 + make_post_sell/templates/player.j2 | 50 ++- make_post_sell/templates/shop_settings.j2 | 67 ++++ make_post_sell/views/player.py | 54 ++++ make_post_sell/views/product.py | 26 ++ make_post_sell/views/shop.py | 34 ++ 11 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 make_post_sell/lib/karaoke.py create mode 100644 make_post_sell/scripts/alembic/versions/a1b2c3d4e5f6_add_unsandbox_keys_to_shop.py create mode 100644 make_post_sell/scripts/backfill_karaoke.py diff --git a/make_post_sell/lib/karaoke.py b/make_post_sell/lib/karaoke.py new file mode 100644 index 0000000..1aaff33 --- /dev/null +++ b/make_post_sell/lib/karaoke.py @@ -0,0 +1,292 @@ +"""Karaoke mode — vocal isolation via unsandbox zerotrust containers. + +Downloads media from S3, sends it + voxsplit.c into a zerotrust container +(no network, destroyed after use), gets back both instrumentals and vocals, +uploads them to S3. + +Uses the unsandbox Python SDK (~/git/un-inception/un.py) for API auth. +""" + +import base64 +import logging +import os +import sys +import tempfile +import threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +log = logging.getLogger(__name__) + +# Use the unsandbox Python SDK for API requests +sys.path.insert(0, os.path.expanduser("~/git/un-inception")) +from un import _make_request, _resolve_credentials # noqa: E402 + +VOXSPLIT_SOURCE = os.environ.get( + "VOXSPLIT_SOURCE_PATH", + os.path.expanduser("~/git/uncloseai-cli/audiotoys/voxsplit.c"), +) + + +def process_karaoke(s3_client, bucket, s3_key, s3_path, + is_video, extension, public_key=None, secret_key=None): + """Download from S3 -> process in unsandbox zerotrust -> upload both tracks. + + Produces two files under s3_path: + {s3_path}/instrumentals + {s3_path}/vocals + + Returns dict {"instrumentals": size, "vocals": size} on success, None on failure. + """ + public_key, secret_key = _resolve_credentials(public_key, secret_key) + + with tempfile.TemporaryDirectory(prefix="karaoke_") as tmpdir: + # 1. Download original from S3 + local_path = os.path.join(tmpdir, "media") + s3_client.download_file(bucket, s3_key, local_path) + + # 2. Read and base64 encode + with open(local_path, "rb") as f: + media_b64 = base64.b64encode(f.read()).decode() + with open(VOXSPLIT_SOURCE, "r") as f: + voxsplit_src = f.read() + voxsplit_b64 = base64.b64encode(voxsplit_src.encode()).decode() + + # 3. Build execution script — produce both tracks + if is_video: + inst_name = f"instrumentals.{extension}" + vocals_name = f"vocals.{extension}" + script = ( + "gcc -O2 -o /tmp/voxsplit /tmp/input/voxsplit.c -lm && " + "/tmp/voxsplit /tmp/input/media -o /tmp/split && " + "mkdir -p /tmp/artifacts && " + f"ffmpeg -y -i /tmp/input/media -i /tmp/split-instrumental.wav " + f"-c:v copy -map 0:v -map 1:a -shortest /tmp/artifacts/{inst_name} && " + f"ffmpeg -y -i /tmp/input/media -i /tmp/split-vocal.wav " + f"-c:v copy -map 0:v -map 1:a -shortest /tmp/artifacts/{vocals_name}" + ) + else: + inst_name = "instrumentals.wav" + vocals_name = "vocals.wav" + script = ( + "gcc -O2 -o /tmp/voxsplit /tmp/input/voxsplit.c -lm && " + "/tmp/voxsplit /tmp/input/media -o /tmp/split && " + "mkdir -p /tmp/artifacts && " + "cp /tmp/split-instrumental.wav /tmp/artifacts/instrumentals.wav && " + "cp /tmp/split-vocal.wav /tmp/artifacts/vocals.wav" + ) + + # 4. Call unsandbox execute API via SDK + payload = { + "language": "bash", + "code": script, + "network_mode": "zerotrust", + "ttl": 300, + "vcpu": 2, + "input_files": [ + {"filename": "media", "content": media_b64}, + {"filename": "voxsplit.c", "content": voxsplit_b64}, + ], + } + + try: + result = _make_request("POST", "/execute", public_key, secret_key, payload) + except Exception: + return None + + if not result.get("success") or result.get("exit_code") != 0: + return None + + # 5. Extract artifacts and upload to S3 + artifacts = result.get("artifacts", []) + if len(artifacts) < 2: + return None + + # Map artifact filenames to track names + artifact_map = {} + for a in artifacts: + fname = a.get("filename", "") + if fname.startswith("instrumentals"): + artifact_map["instrumentals"] = a + elif fname.startswith("vocals"): + artifact_map["vocals"] = a + + if "instrumentals" not in artifact_map or "vocals" not in artifact_map: + return None + + sizes = {} + content_type = "video/mp4" if is_video else "audio/wav" + + for track_name in ("instrumentals", "vocals"): + artifact = artifact_map[track_name] + data = base64.b64decode(artifact["content_base64"]) + local_file = os.path.join(tmpdir, track_name) + with open(local_file, "wb") as f: + f.write(data) + + s3_client.upload_file( + local_file, bucket, f"{s3_path}/{track_name}", + ExtraArgs={ + "CacheControl": "private, max-age=172800", + "ContentType": content_type, + }, + ) + sizes[track_name] = len(data) + + return sizes + + +def tracks_exist(s3_client, bucket, s3_path): + """Check if instrumentals and vocals already exist in S3.""" + for track in ("instrumentals", "vocals"): + try: + s3_client.head_object(Bucket=bucket, Key=f"{s3_path}/{track}") + except Exception: + return False + return True + + +# Background backfill guard — one per shop, same pattern as reforge_discovery_ring_async +_backfill_lock = threading.Lock() +_backfill_running = set() + + +def backfill_karaoke_async(shop_id, session_factory, app_settings): + """Backfill vocal isolation tracks for a shop's catalog using a thread pool. + + Pool size matches the unsandbox account's concurrency limit (from validate_keys). + """ + shop_id_str = str(shop_id) + + with _backfill_lock: + if shop_id_str in _backfill_running: + return # already running for this shop + _backfill_running.add(shop_id_str) + + def _backfill(): + try: + import boto3 + from sqlalchemy.orm import Session as SASession + from ..models.product import Product, get_media_type + from ..models.shop import Shop + from un import validate_keys + + session = SASession(bind=session_factory().get_bind()) + + def _make_s3(): + return boto3.session.Session().client( + "s3", + region_name=app_settings["bucket.secure_uploads.region"], + endpoint_url=app_settings["bucket.secure_uploads.post_endpoint"], + aws_access_key_id=app_settings["bucket.secure_uploads.access_key"], + aws_secret_access_key=app_settings["bucket.secure_uploads.secret_key"], + ) + + s3 = _make_s3() + bucket = app_settings["bucket.secure_uploads"] + + try: + shop = session.get(Shop, shop_id) + if not shop or not shop.unsandbox_public_key or not shop.unsandbox_secret_key: + return + + pk, sk = shop.unsandbox_public_key, shop.unsandbox_secret_key + + # Query account concurrency from unsandbox API + try: + key_info = validate_keys(pk, sk) + concurrency = int(key_info.get("concurrency", 1)) + except Exception: + concurrency = 1 + concurrency = max(1, concurrency) + log.info("Backfill karaoke for shop %s — %d concurrent workers", shop.name, concurrency) + + # Collect work items + work = [] + products = session.query(Product).filter(Product.shop_id == shop_id).all() + for product in products: + for file_key in ("product", "preview"): + ext = product.extensions.get(file_key) + if not ext: + continue + media_type = get_media_type(ext) + if media_type not in ("video", "audio"): + continue + if tracks_exist(s3, bucket, product.s3_path): + continue + work.append((product.id, product.s3_path, product.title, file_key, ext, media_type)) + + if not work: + log.info("No products need backfill for shop %s", shop.name) + return + + MAX_RETRIES = 3 + BACKOFF_BASE = 5 # seconds + + # Process a single product with retries (runs in pool thread) + def _process_one(item): + product_id, s3_path, title, file_key, ext, media_type = item + thread_s3 = _make_s3() + is_video = (media_type == "video") + + for attempt in range(MAX_RETRIES): + log.info("Backfill karaoke: %s (%s) attempt %d/%d", + title, file_key, attempt + 1, MAX_RETRIES) + try: + sizes = process_karaoke( + thread_s3, bucket, + f"{s3_path}/{file_key}", + s3_path, is_video, ext, + public_key=pk, secret_key=sk, + ) + if sizes: + return product_id, title, file_key, ext, media_type, sizes + except Exception: + log.exception("Backfill attempt %d failed for %s", attempt + 1, title) + + if attempt < MAX_RETRIES - 1: + import time as _time + wait = BACKOFF_BASE * (2 ** attempt) + log.info(" Retrying %s in %ds...", title, wait) + _time.sleep(wait) + + return product_id, title, file_key, ext, media_type, None + + # Fan out with thread pool + with ThreadPoolExecutor(max_workers=concurrency) as pool: + futures = {pool.submit(_process_one, item): item for item in work} + for future in as_completed(futures): + try: + product_id, title, file_key, ext, media_type, sizes = future.result() + except Exception: + item = futures[future] + log.exception("Backfill worker crashed for %s", item[2]) + continue + + if sizes: + # DB write only on success — no partial state + product = session.get(Product, product_id) + if not product: + continue + track_ext = ext if media_type == "video" else "wav" + for track_name in ("instrumentals", "vocals"): + product.set_file_metadata(track_name, track_ext, f"{track_name}.{track_ext}") + tmp = product.file_bytes + tmp.update(sizes) + product.file_bytes = tmp + session.add(product) + session.commit() + log.info(" OK %s (inst=%dB, vox=%dB)", title, sizes["instrumentals"], sizes["vocals"]) + else: + log.warning(" FAILED %s after %d attempts", title, MAX_RETRIES) + + except Exception: + session.rollback() + log.exception("Background karaoke backfill failed for shop %s", shop_id_str) + finally: + session.close() + finally: + with _backfill_lock: + _backfill_running.discard(shop_id_str) + + t = threading.Thread(target=_backfill, daemon=True) + t.start() diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index bdc33da..d6caba3 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -168,6 +168,8 @@ class Product(RBase, Base): "thumbnail2", "thumbnail3", "thumbnail4", + "instrumentals", + "vocals", ] file_public_keys = [ diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py index a8dace6..0fa643a 100644 --- a/make_post_sell/models/shop.py +++ b/make_post_sell/models/shop.py @@ -100,6 +100,10 @@ class Shop(RBase, Base): adyen_hmac_key = Column(Unicode(128), nullable=True) adyen_enabled = Column(Boolean, default=True) + # Unsandbox API credentials for karaoke vocal isolation + unsandbox_public_key = Column(Unicode(128), nullable=True) + unsandbox_secret_key = Column(Unicode(128), nullable=True) + created_timestamp = Column(BigInteger, nullable=False) updated_timestamp = Column(BigInteger, nullable=False) diff --git a/make_post_sell/scripts/alembic/versions/a1b2c3d4e5f6_add_unsandbox_keys_to_shop.py b/make_post_sell/scripts/alembic/versions/a1b2c3d4e5f6_add_unsandbox_keys_to_shop.py new file mode 100644 index 0000000..3e5032b --- /dev/null +++ b/make_post_sell/scripts/alembic/versions/a1b2c3d4e5f6_add_unsandbox_keys_to_shop.py @@ -0,0 +1,40 @@ +"""add unsandbox API keys to shop + +Revision ID: a1b2c3d4e5f6 +Revises: d4c7d6a1e961 +Create Date: 2026-02-17 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a1b2c3d4e5f6' +down_revision = 'd4c7d6a1e961' +branch_labels = None +depends_on = None + + +def _column_exists(table, column): + conn = op.get_bind() + result = conn.execute(sa.text(f"PRAGMA table_info({table})")) + return any(row[1] == column for row in result.fetchall()) + + +def upgrade(): + if not _column_exists("mps_shop", "unsandbox_public_key"): + op.add_column( + "mps_shop", + sa.Column("unsandbox_public_key", sa.Unicode(128), nullable=True), + ) + if not _column_exists("mps_shop", "unsandbox_secret_key"): + op.add_column( + "mps_shop", + sa.Column("unsandbox_secret_key", sa.Unicode(128), nullable=True), + ) + + +def downgrade(): + op.drop_column("mps_shop", "unsandbox_secret_key") + op.drop_column("mps_shop", "unsandbox_public_key") diff --git a/make_post_sell/scripts/backfill_karaoke.py b/make_post_sell/scripts/backfill_karaoke.py new file mode 100644 index 0000000..ad1a475 --- /dev/null +++ b/make_post_sell/scripts/backfill_karaoke.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Backfill vocal isolation tracks for all existing audio/video products. + +Usage: + python -m make_post_sell.scripts.backfill_karaoke production.ini + python -m make_post_sell.scripts.backfill_karaoke production.ini --execute +""" + +import argparse +import sys + +from pyramid.paster import bootstrap + +from make_post_sell.models.product import Product, get_media_type +from make_post_sell.lib.karaoke import process_karaoke, tracks_exist + + +def backfill(env, dry_run=True): + request = env["request"] + dbsession = request.dbsession + s3 = request.secure_uploads_client + bucket = request.app["bucket.secure_uploads"] + + products = dbsession.query(Product).all() + print(f"Found {len(products)} products to scan") + print(f"Dry run: {dry_run}") + print("-" * 50) + + processed = 0 + skipped = 0 + errors = 0 + + for product in products: + shop = product.shop + if not shop.unsandbox_public_key or not shop.unsandbox_secret_key: + continue + + for file_key in ("product", "preview"): + ext = product.extensions.get(file_key) + if not ext: + continue + media_type = get_media_type(ext) + if media_type not in ("video", "audio"): + continue + + s3_key = f"{product.s3_path}/{file_key}" + + if tracks_exist(s3, bucket, product.s3_path): + print(f"SKIP {product.title} ({file_key}) — tracks already exist") + skipped += 1 + continue + + if dry_run: + print(f"WOULD PROCESS {product.title} ({file_key})") + continue + + print(f"PROCESSING {product.title} ({file_key})...") + is_video = (media_type == "video") + sizes = process_karaoke( + s3, bucket, s3_key, product.s3_path, is_video, ext, + public_key=shop.unsandbox_public_key, + secret_key=shop.unsandbox_secret_key, + ) + if sizes: + track_ext = ext if is_video else "wav" + for track_name in ("instrumentals", "vocals"): + product.set_file_metadata(track_name, track_ext, f"{track_name}.{track_ext}") + tmp = product.file_bytes + tmp.update(sizes) + product.file_bytes = tmp + dbsession.add(product) + dbsession.flush() + print(f" OK (instrumentals: {sizes['instrumentals']}B, vocals: {sizes['vocals']}B)") + processed += 1 + else: + print(f" FAILED") + errors += 1 + + if not dry_run: + import transaction + transaction.commit() + + print("-" * 50) + print(f"Processed: {processed} Skipped: {skipped} Errors: {errors} Dry run: {dry_run}") + + +def main(): + parser = argparse.ArgumentParser( + description="Backfill vocal isolation tracks for audio/video products" + ) + parser.add_argument("config_uri", help="Configuration file (e.g., production.ini)") + parser.add_argument( + "--execute", action="store_true", + help="Actually process files (default is dry run)", + ) + args = parser.parse_args() + + with bootstrap(args.config_uri) as env: + try: + backfill(env, dry_run=not args.execute) + except KeyboardInterrupt: + print("\nCancelled") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/make_post_sell/static/js/player.js b/make_post_sell/static/js/player.js index d380975..ab79790 100644 --- a/make_post_sell/static/js/player.js +++ b/make_post_sell/static/js/player.js @@ -385,6 +385,13 @@ togglePip(); } break; + case 'k': + case 'K': + e.preventDefault(); + if (typeof toggleKaraoke === 'function') { + toggleKaraoke(); + } + break; } } diff --git a/make_post_sell/templates/player.j2 b/make_post_sell/templates/player.j2 index d360c10..cda7e8d 100644 --- a/make_post_sell/templates/player.j2 +++ b/make_post_sell/templates/player.j2 @@ -237,7 +237,11 @@ - + {% if instrumentals_url or vocals_url %} + + {% else %} + + {% endif %} @@ -252,9 +256,51 @@ productId: "{{ product.id }}", productUrl: "{{ product_url }}", prevProductUrl: {{ ('"%s"' % prev_product_url) if prev_product_url else 'null' }}, - nextProductUrl: {{ ('"%s"' % next_product_url) if next_product_url else 'null' }} + nextProductUrl: {{ ('"%s"' % next_product_url) if next_product_url else 'null' }}, + instrumentalsUrl: {{ ('"%s"' % instrumentals_url) if instrumentals_url else 'null' }}, + vocalsUrl: {{ ('"%s"' % vocals_url) if vocals_url else 'null' }} }; + // Karaoke toggle — cycles: Original → Instrumentals → Vocals → Original + let originalSrc = null; + const trackModes = []; + (function() { + trackModes.push({name: 'Original', url: null}); + if (window.playerData.instrumentalsUrl) + trackModes.push({name: 'Instrumentals', url: window.playerData.instrumentalsUrl}); + if (window.playerData.vocalsUrl) + trackModes.push({name: 'Vocals', url: window.playerData.vocalsUrl}); + })(); + let trackIndex = 0; + + function toggleKaraoke() { + const media = document.getElementById('media'); + const btn = document.getElementById('karaokeBtn'); + if (!media || trackModes.length <= 1) return; + + if (!originalSrc) originalSrc = media.src; + + const currentTime = media.currentTime; + const wasPlaying = !media.paused; + + trackIndex = (trackIndex + 1) % trackModes.length; + const mode = trackModes[trackIndex]; + + media.src = mode.url || originalSrc; + btn.textContent = '🎤 ' + mode.name; + if (trackIndex === 0) { + btn.classList.remove('active'); + } else { + btn.classList.add('active'); + } + + media.addEventListener('loadedmetadata', function once() { + media.currentTime = currentTime; + if (wasPlaying) media.play(); + media.removeEventListener('loadedmetadata', once); + }); + } + // Go to the product page (always opens in new tab) function goToProductPage() { window.open(window.playerData.productUrl, '_blank'); diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2 index dbe442b..37c8971 100644 --- a/make_post_sell/templates/shop_settings.j2 +++ b/make_post_sell/templates/shop_settings.j2 @@ -390,6 +390,73 @@
{% endif %} +
+
+ +

Unsandbox Settings 🎤

+

API keys for vocal isolation (karaoke mode). Media is processed in zerotrust containers — no network, destroyed after use.

+ +
+ + +
+ Show Unsandbox API Keys + +
+ +
+ + + + +
+
+ + + + +
+
+ + {% if request.shop.unsandbox_public_key and request.shop.unsandbox_secret_key %} + ✓ Unsandbox configured — karaoke mode active on new uploads + {% else %} + Add your unsandbox.com API keys to enable vocal isolation on audio/video products. + {% endif %} + +
+
+ + + +
+
+ +
+
+ +
+ +
+
+ +
+
+ {% if request.monero_enabled %}
diff --git a/make_post_sell/views/player.py b/make_post_sell/views/player.py index 5d5fb20..b2176d4 100644 --- a/make_post_sell/views/player.py +++ b/make_post_sell/views/player.py @@ -117,6 +117,31 @@ def player(request): if next_product_id and current_index is not None and current_index < len(media_products) - 1: next_product_url = media_products[current_index + 1].absolute_url(request) + # Generate presigned URLs for isolated tracks if available + instrumentals_url = None + vocals_url = None + if media_type in ("video", "audio"): + for track_name, attr in (("instrumentals", "instrumentals_url"), ("vocals", "vocals_url")): + if track_name in product.extensions: + track_ext = product.extensions.get(track_name) + track_ct = product.get_content_type(track_name) or ( + "video/mp4" if media_type == "video" else "audio/wav" + ) + url = request.secure_uploads_client.generate_presigned_url( + ClientMethod="get_object", + Params={ + "Bucket": bucket_name, + "Key": f"{product.s3_path}/{track_name}", + "ResponseContentDisposition": f'inline; filename="{product.title} ({track_name.title()}).{track_ext}"', + "ResponseContentType": track_ct, + }, + ExpiresIn=900, + ) + if track_name == "instrumentals": + instrumentals_url = url + else: + vocals_url = url + return { "product": product, "presigned_url": presigned_url, @@ -127,6 +152,8 @@ def player(request): "product_url": product_url, "prev_product_url": prev_product_url, "next_product_url": next_product_url, + "instrumentals_url": instrumentals_url, + "vocals_url": vocals_url, } @@ -222,6 +249,31 @@ def player_json(request): if current_index < len(media_products) - 1: next_product_id = str(media_products[current_index + 1].id) + # Generate presigned URLs for isolated tracks if available + instrumentals_url = None + vocals_url = None + if media_type in ("video", "audio"): + for track_name in ("instrumentals", "vocals"): + if track_name in product.extensions: + track_ext = product.extensions.get(track_name) + track_ct = product.get_content_type(track_name) or ( + "video/mp4" if media_type == "video" else "audio/wav" + ) + url = request.secure_uploads_client.generate_presigned_url( + ClientMethod="get_object", + Params={ + "Bucket": bucket_name, + "Key": f"{product.s3_path}/{track_name}", + "ResponseContentDisposition": f'inline; filename="{product.title} ({track_name.title()}).{track_ext}"', + "ResponseContentType": track_ct, + }, + ExpiresIn=900, + ) + if track_name == "instrumentals": + instrumentals_url = url + else: + vocals_url = url + return { "success": True, "videoUrl": presigned_url, @@ -230,6 +282,8 @@ def player_json(request): "productId": str(product.id), "prevProductId": prev_product_id, "nextProductId": next_product_id, + "instrumentalsUrl": instrumentals_url, + "vocalsUrl": vocals_url, } diff --git a/make_post_sell/views/product.py b/make_post_sell/views/product.py index 7af8a24..28f0469 100644 --- a/make_post_sell/views/product.py +++ b/make_post_sell/views/product.py @@ -365,6 +365,32 @@ def product_edit(request): product.shop.id, request.registry["dbsession_factory"] ) + # Generate vocal isolation tracks if this is audio/video + from ..models.product import get_media_type + from ..lib.karaoke import process_karaoke + upload_media_type = get_media_type(product.extensions.get(file_key)) + shop = product.shop + if upload_media_type in ("video", "audio") and shop.unsandbox_public_key and shop.unsandbox_secret_key: + is_video = (upload_media_type == "video") + ext = product.extensions.get(file_key) + sizes = process_karaoke( + request.secure_uploads_client, + request.app["bucket.secure_uploads"], + f"{product.s3_path}/{file_key}", + product.s3_path, is_video, ext, + public_key=shop.unsandbox_public_key, + secret_key=shop.unsandbox_secret_key, + ) + if sizes: + track_ext = ext if is_video else "wav" + for track_name in ("instrumentals", "vocals"): + product.set_file_metadata(track_name, track_ext, f"{track_name}.{track_ext}") + tmp = product.file_bytes + tmp.update(sizes) + product.file_bytes = tmp + request.dbsession.add(product) + request.dbsession.flush() + # redirect back to this page to clear # the params posted by the s3 webhooks. return HTTPFound(f"{product.absolute_edit_url(request)}?uploaded={file_key}") diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py index c93986e..0484751 100644 --- a/make_post_sell/views/shop.py +++ b/make_post_sell/views/shop.py @@ -771,6 +771,38 @@ def shop_settings(request): shop.adyen_hmac_key = adyen_hmac_key request.session.flash(("You set the shop's Adyen HMAC Key.", "success")) + # Handle unsandbox settings form + if form_section == "unsandbox-settings": + unsandbox_public_key = request.params.get("unsandbox_public_key", "").strip() + unsandbox_secret_key = request.params.get("unsandbox_secret_key", "").strip() + keys_changed = False + + if unsandbox_public_key and unsandbox_public_key != (shop.unsandbox_public_key or ""): + if unsandbox_public_key.startswith("unsb-pk-"): + shop.unsandbox_public_key = unsandbox_public_key + keys_changed = True + request.session.flash(("Unsandbox public key saved.", "success")) + else: + request.session.flash(("Unsandbox public key must start with 'unsb-pk-'.", "error")) + + if unsandbox_secret_key and unsandbox_secret_key != (shop.unsandbox_secret_key or ""): + if unsandbox_secret_key.startswith("unsb-sk-"): + shop.unsandbox_secret_key = unsandbox_secret_key + keys_changed = True + request.session.flash(("Unsandbox secret key saved.", "success")) + else: + request.session.flash(("Unsandbox secret key must start with 'unsb-sk-'.", "error")) + + # Backfill existing catalog when both keys are now present + if keys_changed and shop.unsandbox_public_key and shop.unsandbox_secret_key: + from ..lib.karaoke import backfill_karaoke_async + backfill_karaoke_async( + shop.id, + request.registry["dbsession_factory"], + request.app, + ) + request.session.flash(("Backfilling vocal isolation for existing catalog in background.", "success")) + # Handle maintenance settings form if form_section == "maintenance-settings": if shop.maint_mode != maint_mode: @@ -1067,6 +1099,8 @@ def shop_settings(request): ), "xmr_processor": xmr_processor, "doge_processor": doge_processor, + "unsandbox_public_key": shop.unsandbox_public_key or "", + "unsandbox_secret_key": shop.unsandbox_secret_key or "", "signed_posts": signed_posts, "get_endpoints": get_endpoints, }