add karaoke mode: vocal isolation via unsandbox zerotrust containers

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.
This commit is contained in:
russell@unturf.com 2026-02-17 23:46:45 -05:00
parent 6e40886cd7
commit 1135eb24e4
11 changed files with 681 additions and 2 deletions

View file

@ -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()

View file

@ -168,6 +168,8 @@ class Product(RBase, Base):
"thumbnail2",
"thumbnail3",
"thumbnail4",
"instrumentals",
"vocals",
]
file_public_keys = [

View file

@ -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)

View file

@ -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")

View file

@ -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()

View file

@ -385,6 +385,13 @@
togglePip();
}
break;
case 'k':
case 'K':
e.preventDefault();
if (typeof toggleKaraoke === 'function') {
toggleKaraoke();
}
break;
}
}

View file

@ -237,7 +237,11 @@
<!-- Row 3: More options -->
<button id="autoResizeBtn" onclick="toggleAutoResize()" title="Auto-Resize Window (R)">↔ Resize</button>
<button onclick="window.close()" title="Close Window (ESC)">✕ Close</button>
<span></span><!-- Empty cell for grid alignment -->
{% if instrumentals_url or vocals_url %}
<button id="karaokeBtn" onclick="toggleKaraoke()" title="Karaoke Mode (K)">🎤 Original</button>
{% else %}
<span></span>
{% endif %}
</div>
</div>
</div>
@ -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');

View file

@ -390,6 +390,73 @@
<br />
{% endif %}
<section class="one-column">
<section class="shop-settings well">
<h3>Unsandbox Settings 🎤</h3>
<p>API keys for vocal isolation (karaoke mode). Media is processed in zerotrust containers — no network, destroyed after use.</p>
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
<input type="hidden" name="form_section" value="unsandbox-settings" />
<details class="api-key-details">
<summary class="toggle-summary">Show Unsandbox API Keys</summary>
<div class="api-key-fields">
<br />
<label for="unsandbox_public_key_input">Public Key</label>
<input
name = "unsandbox_public_key"
type = "text"
id = "unsandbox_public_key_input"
class = "full-width-input"
value = "{% if unsandbox_public_key %}{{ unsandbox_public_key }}{% endif %}"
placeholder = "unsb-pk-xxxx-xxxx-xxxx-xxxx"
/>
<br />
<br />
<label for="unsandbox_secret_key_input">Secret Key</label>
<input
name = "unsandbox_secret_key"
type = "text"
id = "unsandbox_secret_key_input"
class = "full-width-input"
value = "{% if unsandbox_secret_key %}{{ unsandbox_secret_key }}{% endif %}"
placeholder = "unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
/>
<br />
<br />
{% if request.shop.unsandbox_public_key and request.shop.unsandbox_secret_key %}
<small class="success-indicator">✓ Unsandbox configured — karaoke mode active on new uploads</small>
{% else %}
<small class="status-message">Add your unsandbox.com API keys to enable vocal isolation on audio/video products.</small>
{% endif %}
<br />
<br />
<input type="submit" name="submit" class="mps-submit" value="Save Unsandbox Settings" />
</div>
</details>
<br />
<br />
</form>
</section>
</section>
<br />
<br />
{% if request.monero_enabled %}
<section class="one-column">
<section class="shop-settings well">

View file

@ -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,
}

View file

@ -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}")

View file

@ -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,
}