feat: on-demand karaoke processing — button next to random triggers vocal isolation

Karaoke button (🎤) now always visible for audio/video products when the
shop has unsandbox API keys configured. Clicking it triggers on-demand
processing via POST /karaoke/{product_id} if tracks don't exist yet.

Server forks a detached child process (survives uWSGI recycling) to run
process_karaoke, then the existing 10s watch_json refresh picks up the
new URLs when processing completes. Button shows hourglass during
processing and auto-switches to instrumentals on completion.

Changes:
- New route + view: POST /karaoke/{product_id} (on-demand processing)
- watch_json + content.py: add karaoke_eligible flag
- content.j2: data-karaoke-eligible attribute on media container
- watch.js: show button when eligible, trigger processing, detect
  completion via URL refresh, auto-switch to instrumentals
- related_content.j2: mic emoji button, JS controls visibility
- CSS: disabled state for processing button
- 8 new functional tests covering eligibility, processing, edge cases
This commit is contained in:
russell@unturf.com 2026-03-11 17:20:26 -04:00
parent 924921e232
commit 4a12722b4d
8 changed files with 392 additions and 9 deletions

View file

@ -188,6 +188,7 @@ def includeme(config):
config.add_route("player", "/player/{product_id}")
config.add_route("player_json", "/player/{product_id}/json")
config.add_route("watch_json", "/watch/{product_id}/json")
config.add_route("karaoke_process", "/karaoke/{product_id}")
config.add_route("random", "/random")
config.add_route("tv", "/tv")

View file

@ -3045,6 +3045,10 @@ textarea {
color: white;
border-color: var(--blue-color, #5871ad);
}
.ring-karaoke-btn:disabled {
cursor: wait;
opacity: 0.6;
}
.related-content-row-current {
background-color: var(--blue-color, #5871ad);
color: white;

View file

@ -35,6 +35,8 @@
var karaokeMode = 0; // 0=original, 1=instrumentals, 2=vocals
var KARAOKE_LABELS = ['Original', 'Instrumentals', 'Vocals'];
var karaokeUrls = {original: null, instrumentals: null, vocals: null};
var karaokeEligible = false;
var karaokeProcessing = false;
// --- Ring state (persisted in localStorage) ---
var ringProductIds = [];
@ -240,6 +242,8 @@
karaokeUrls.instrumentals = container ? container.getAttribute('data-instrumentals-url') : null;
karaokeUrls.vocals = container ? container.getAttribute('data-vocals-url') : null;
karaokeUrls.original = activeMedia ? activeMedia.src : null;
karaokeEligible = container ? container.getAttribute('data-karaoke-eligible') === '1' : false;
karaokeProcessing = false;
karaokeMode = 0;
updateKaraokeButton();
}
@ -248,12 +252,29 @@
var btn = document.getElementById('karaoke-toggle-btn');
if (!btn) return;
var hasKaraoke = !!(karaokeUrls.instrumentals || karaokeUrls.vocals);
btn.style.display = hasKaraoke ? '' : 'none';
btn.textContent = KARAOKE_LABELS[karaokeMode];
if (karaokeMode === 0) {
btn.classList.remove('active');
} else {
// Show button when tracks exist OR when eligible for on-demand processing
btn.style.display = (hasKaraoke || karaokeEligible) ? '' : 'none';
if (karaokeProcessing) {
btn.textContent = '\u231B'; // hourglass
btn.title = 'Processing karaoke tracks\u2026';
btn.classList.add('active');
btn.disabled = true;
} else if (hasKaraoke) {
btn.textContent = KARAOKE_LABELS[karaokeMode];
btn.title = 'Cycle: Original / Instrumentals / Vocals';
btn.disabled = false;
if (karaokeMode === 0) {
btn.classList.remove('active');
} else {
btn.classList.add('active');
}
} else {
// Eligible but no tracks yet — show mic icon
btn.textContent = '\uD83C\uDFA4'; // 🎤
btn.title = 'Generate karaoke tracks';
btn.classList.remove('active');
btn.disabled = false;
}
}
@ -267,9 +288,45 @@
return modes[(idx + 1) % modes.length];
}
function triggerKaraokeProcessing() {
if (karaokeProcessing || !currentProductId) return;
karaokeProcessing = true;
updateKaraokeButton();
fetch('/karaoke/' + currentProductId, {method: 'POST'})
.then(function(resp) { return resp.json(); })
.then(function(data) {
if (data.status === 'ready') {
// Tracks already exist — refresh to get URLs
karaokeProcessing = false;
fetchWatchData(currentProductId).then(function(fresh) {
if (fresh && fresh.instrumentals_url) {
karaokeUrls.instrumentals = fresh.instrumentals_url;
karaokeUrls.vocals = fresh.vocals_url;
karaokeUrls.original = fresh.media_url;
updateKaraokeButton();
// Auto-switch to instrumentals
cycleKaraoke();
}
});
}
// status === 'processing' — polling via URL refresh will pick it up
})
.catch(function() {
karaokeProcessing = false;
updateKaraokeButton();
});
}
function cycleKaraoke() {
if (!activeMedia) return;
var hasKaraoke = !!(karaokeUrls.instrumentals || karaokeUrls.vocals);
// No tracks yet but eligible — trigger on-demand processing
if (!hasKaraoke && karaokeEligible) {
triggerKaraokeProcessing();
return;
}
if (!hasKaraoke) return;
var nextMode = getNextKaraokeMode();
@ -678,6 +735,8 @@
// Reset karaoke state for the new product
karaokeMode = 0;
karaokeProcessing = false;
karaokeEligible = !!data.karaoke_eligible;
karaokeUrls.original = data.media_url || null;
karaokeUrls.instrumentals = data.instrumentals_url || null;
karaokeUrls.vocals = data.vocals_url || null;
@ -903,7 +962,7 @@
+ '<input type="checkbox" id="watch-autoplay-toggle"' + autoplayChecked + ' />'
+ '<span class="autoplay-slider"></span>'
+ '</label>'
+ '<button id="karaoke-toggle-btn" class="ring-karaoke-btn mps-button js-only" style="display:' + (karaokeUrls.instrumentals || karaokeUrls.vocals ? '' : 'none') + '" title="Cycle: Original / Instrumentals / Vocals">' + KARAOKE_LABELS[karaokeMode] + '</button>'
+ '<button id="karaoke-toggle-btn" class="ring-karaoke-btn mps-button js-only" style="display:' + (karaokeUrls.instrumentals || karaokeUrls.vocals || karaokeEligible ? '' : 'none') + '" title="Karaoke"></button>'
+ '<button id="skip-random-btn" class="skip-next-btn mps-button js-only" title="Jump to random item">&#127922;</button>'
+ '<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>'
+ '</div>'
@ -1567,9 +1626,19 @@
if (data.product_id !== currentProductId) { scheduleUrlRefresh(); return; }
// Refresh karaoke URLs from the fresh response
var hadKaraoke = !!(karaokeUrls.instrumentals || karaokeUrls.vocals);
karaokeUrls.original = data.media_url;
if (data.instrumentals_url) karaokeUrls.instrumentals = data.instrumentals_url;
if (data.vocals_url) karaokeUrls.vocals = data.vocals_url;
if (data.karaoke_eligible !== undefined) karaokeEligible = !!data.karaoke_eligible;
// Processing just completed — tracks appeared
if (!hadKaraoke && karaokeProcessing && (data.instrumentals_url || data.vocals_url)) {
karaokeProcessing = false;
updateKaraokeButton();
// Auto-switch to instrumentals
cycleKaraoke();
}
var wasPlaying = !activeMedia.paused;
var savedTime = activeMedia.currentTime;

View file

@ -40,7 +40,7 @@
{% if request.shop.watch_mode_enabled and product.extensions.get("product") in video_extensions %}
{# Watch mode: direct video render with autoplay #}
{% set watch_video_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}{% if karaoke_eligible %} data-karaoke-eligible="1"{% endif %}>
<div class="watch-video-container">
<video id="watch-video" src="{{ watch_video_url }}" autoplay controls playsinline class="product-main"></video>
<button class="unmute-overlay">Tap to unmute</button>
@ -61,7 +61,7 @@
{% elif request.shop.watch_mode_enabled and product.extensions.get("product") in audio_extensions %}
{# Watch mode: audio with album art #}
{% set watch_audio_url = request.shop_cdn_endpoint ~ "/" ~ product.s3_path ~ "/product" %}
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}>
<div id="watch-media-container"{% if instrumentals_url %} data-instrumentals-url="{{ instrumentals_url }}"{% endif %}{% if vocals_url %} data-vocals-url="{{ vocals_url }}"{% endif %}{% if karaoke_eligible %} data-karaoke-eligible="1"{% endif %}>
<div class="watch-audio-container">
{% if "thumbnail1" in product.extensions %}
<img src="{{ request.shop_cdn_endpoint }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />

View file

@ -29,7 +29,7 @@
<input type="checkbox" id="watch-autoplay-toggle" checked />
<span class="autoplay-slider"></span>
</label>
<button id="karaoke-toggle-btn" class="ring-karaoke-btn mps-button js-only" style="display:none" title="Cycle: Original / Instrumentals / Vocals">Original</button>
<button id="karaoke-toggle-btn" class="ring-karaoke-btn mps-button js-only" style="display:none" title="Karaoke: play instrumental version">&#127908;</button>
<button id="skip-random-btn" class="skip-next-btn mps-button js-only" title="Jump to random item">&#127922;</button>
<button id="skip-next-btn" class="skip-next-btn mps-button js-only" title="Skip to next unwatched">Next &#9654;</button>
</div>

View file

@ -3148,6 +3148,122 @@ class AuthenticatedFunctionalTests(_AuthenticatedBase):
self.assertNotIn("data-instrumentals-url", body)
self.assertNotIn("data-vocals-url", body)
def test_watch_json_karaoke_eligible_with_keys(self):
"""Watch JSON returns karaoke_eligible=True when shop has unsandbox keys."""
product_id, _ = self._create_content_with_metadata(
"karaoke-elig-shop", "Eligible Song", "Has keys",
{"extensions": {"product": "mp3"}, "file_bytes": {"product": 5000000}},
)
# Set unsandbox keys on the shop
from ..models.product import Product
product = self.dbsession.query(Product).get(product_id)
product.shop.unsandbox_public_key = "pk_test_123"
product.shop.unsandbox_secret_key = "sk_test_456"
self.dbsession.flush()
transaction.commit()
res = self.testapp.get(f"/watch/{product_id}/json", status=200)
data = res.json
self.assertTrue(data["karaoke_eligible"])
# No tracks yet — URLs should be null
self.assertIsNone(data["instrumentals_url"])
self.assertIsNone(data["vocals_url"])
def test_watch_json_karaoke_not_eligible_without_keys(self):
"""Watch JSON returns karaoke_eligible=False when shop has no unsandbox keys."""
product_id, _ = self._create_content_with_metadata(
"karaoke-noelig-shop", "No Keys Song", "No unsandbox",
{"extensions": {"product": "mp3"}, "file_bytes": {"product": 5000000}},
)
res = self.testapp.get(f"/watch/{product_id}/json", status=200)
data = res.json
self.assertFalse(data["karaoke_eligible"])
def test_content_page_karaoke_eligible_attr(self):
"""Content page embeds data-karaoke-eligible when shop has unsandbox keys."""
product_id, product_slug = self._create_content_with_metadata(
"karaoke-elig-content-shop", "Eligible Content", "Has unsandbox",
{"extensions": {"product": "mp3"}, "file_bytes": {"product": 5000000}},
)
from ..models.product import Product
product = self.dbsession.query(Product).get(product_id)
product.shop.unsandbox_public_key = "pk_test_123"
product.shop.unsandbox_secret_key = "sk_test_456"
self.dbsession.flush()
transaction.commit()
res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200)
body = res.body.decode()
self.assertIn('data-karaoke-eligible="1"', body)
def test_content_page_no_karaoke_eligible_without_keys(self):
"""Content page omits karaoke-eligible attr when no unsandbox keys."""
product_id, product_slug = self._create_content_with_metadata(
"karaoke-noelig-content-shop", "No Keys Content", "No unsandbox",
{"extensions": {"product": "mp3"}, "file_bytes": {"product": 5000000}},
)
res = self.testapp.get(f"/c/{product_id}/{product_slug}", status=200)
body = res.body.decode()
self.assertNotIn("data-karaoke-eligible", body)
def test_karaoke_process_not_found(self):
"""POST /karaoke/{bad_id} returns 404."""
res = self.testapp.post("/karaoke/nonexistent-id", expect_errors=True)
self.assertEqual(res.status_int, 404)
def test_karaoke_process_no_keys(self):
"""POST /karaoke/{id} returns 400 when shop has no unsandbox keys."""
product_id, _ = self._create_content_with_metadata(
"karaoke-proc-nokeys-shop", "No Keys Proc", "No unsandbox",
{"extensions": {"product": "mp3"}, "file_bytes": {"product": 5000000}},
)
res = self.testapp.post(f"/karaoke/{product_id}", expect_errors=True)
self.assertEqual(res.status_int, 400)
self.assertIn("not configured", res.json["error"])
def test_karaoke_process_already_has_tracks(self):
"""POST /karaoke/{id} returns ready when tracks already exist."""
product_id, _ = self._create_content_with_metadata(
"karaoke-proc-ready-shop", "Already Done", "Has tracks",
{
"extensions": {"product": "mp3", "instrumentals": "wav", "vocals": "wav"},
"file_bytes": {"product": 5000000, "instrumentals": 8000000, "vocals": 8000000},
},
)
from ..models.product import Product
product = self.dbsession.query(Product).get(product_id)
product.shop.unsandbox_public_key = "pk_test_123"
product.shop.unsandbox_secret_key = "sk_test_456"
self.dbsession.flush()
transaction.commit()
res = self.testapp.post(f"/karaoke/{product_id}", status=200)
self.assertEqual(res.json["status"], "ready")
def test_karaoke_process_image_not_eligible(self):
"""POST /karaoke/{id} returns 400 for image products."""
product_id, _ = self._create_content_with_metadata(
"karaoke-proc-img-shop", "An Image", "Not audio",
{"extensions": {"product": "jpg"}, "file_bytes": {"product": 500000}},
)
from ..models.product import Product
product = self.dbsession.query(Product).get(product_id)
product.shop.unsandbox_public_key = "pk_test_123"
product.shop.unsandbox_secret_key = "sk_test_456"
self.dbsession.flush()
transaction.commit()
res = self.testapp.post(f"/karaoke/{product_id}", expect_errors=True)
self.assertEqual(res.status_int, 400)
self.assertIn("Not audio or video", res.json["error"])
def test_rss_autodiscovery_links(self):
"""Test that RSS and Atom autodiscovery links are present in shop pages."""
shop = self._create_shop_helper(

View file

@ -85,9 +85,12 @@ def content(request):
# Generate CDN URLs for karaoke tracks if available
instrumentals_url = None
vocals_url = None
karaoke_eligible = False
extension = product.extensions.get("product")
media_type = get_media_type(extension) if extension else None
if media_type in ("video", "audio"):
shop = product.shop
karaoke_eligible = bool(shop.unsandbox_public_key and shop.unsandbox_secret_key)
cdn_base = request.shop_cdn_endpoint
for track_name in ("instrumentals", "vocals"):
if track_name in product.extensions:
@ -106,4 +109,5 @@ def content(request):
"related_products": related_products,
"instrumentals_url": instrumentals_url,
"vocals_url": vocals_url,
"karaoke_eligible": karaoke_eligible,
}

View file

@ -1,3 +1,7 @@
import fcntl
import logging
import os
from pyramid.view import view_config
from ..models.product import get_media_type, get_related_products, get_ring_related_products
@ -5,6 +9,8 @@ from ..models.shop import get_shop_by_id
from ..lib.currency import cents_to_dollars
from ..lib.time_funcs import timestamp_to_ago_string, timestamp_to_datetime
log = logging.getLogger(__name__)
@view_config(route_name="watch_json", renderer="json")
def watch_json(request):
@ -68,7 +74,10 @@ def watch_json(request):
# Generate presigned URLs for karaoke tracks if available
instrumentals_url = None
vocals_url = None
karaoke_eligible = False
if media_type in ("video", "audio"):
shop = request.shop
karaoke_eligible = bool(shop.unsandbox_public_key and shop.unsandbox_secret_key)
for track_name in ("instrumentals", "vocals"):
if track_name in product.extensions:
track_ext = product.extensions.get(track_name)
@ -223,6 +232,7 @@ def watch_json(request):
"comments_enabled": request.shop.comments_enabled,
"instrumentals_url": instrumentals_url,
"vocals_url": vocals_url,
"karaoke_eligible": karaoke_eligible,
}
if price_history:
@ -231,6 +241,185 @@ def watch_json(request):
return result
@view_config(route_name="karaoke_process", renderer="json", request_method="POST")
def karaoke_process(request):
"""On-demand karaoke processing for a single product.
Forks a detached child process (survives uWSGI recycling) that runs
process_karaoke and updates the DB. Returns immediately with status.
The watch_json 10s refresh loop picks up the new URLs when done.
"""
product_id = request.matchdict.get("product_id")
from ..models.product import Product
try:
product = request.dbsession.query(Product).get(product_id)
except Exception:
product = None
if not product:
request.response.status_int = 404
return {"error": "Product not found"}
shop = product.shop
if not shop.unsandbox_public_key or not shop.unsandbox_secret_key:
request.response.status_int = 400
return {"error": "Karaoke not configured for this shop"}
# Determine which file to process
if product.is_sellable:
file_key = "preview"
else:
file_key = "product"
extension = product.extensions.get(file_key)
if not extension:
request.response.status_int = 400
return {"error": "No media file"}
media_type = get_media_type(extension)
if media_type not in ("video", "audio"):
request.response.status_int = 400
return {"error": "Not audio or video"}
# Already has tracks?
if "instrumentals" in product.extensions and "vocals" in product.extensions:
return {"status": "ready"}
# One-at-a-time guard per product
lockfile = f"/tmp/karaoke_{product_id}.lock"
try:
check_fd = open(lockfile, "w")
fcntl.flock(check_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
fcntl.flock(check_fd, fcntl.LOCK_UN)
check_fd.close()
except (IOError, OSError):
return {"status": "processing"}
# Capture values before fork
db_url = str(request.dbsession.get_bind().url)
s3_path = product.s3_path
s3_key = f"{s3_path}/{file_key}"
is_video = media_type == "video"
pk = shop.unsandbox_public_key
sk = shop.unsandbox_secret_key
app_settings = request.registry.settings
has_mirror = shop.has_s3_mirror
shop_id = shop.id
# BYOB credentials
if shop.has_primary_s3:
s3_region = shop.primary_s3_region
s3_endpoint = shop.primary_s3_endpoint
s3_access = shop.primary_s3_access_key
s3_secret = shop.primary_s3_secret_key
bucket = shop.primary_s3_bucket
else:
s3_region = app_settings["bucket.secure_uploads.region"]
s3_endpoint = app_settings["bucket.secure_uploads.post_endpoint"]
s3_access = app_settings["bucket.secure_uploads.access_key"]
s3_secret = app_settings["bucket.secure_uploads.secret_key"]
bucket = app_settings["bucket.secure_uploads"]
pid = os.fork()
if pid > 0:
os.waitpid(pid, 0)
return {"status": "processing"}
# Intermediate child: detach from uWSGI
os.setsid()
pid2 = os.fork()
if pid2 > 0:
os._exit(0)
# --- Grandchild: fully detached ---
import resource
maxfd = resource.getrlimit(resource.RLIMIT_NOFILE)[1]
if maxfd == resource.RLIM_INFINITY:
maxfd = 1024
for fd in range(3, maxfd):
try:
os.close(fd)
except OSError:
pass
lock_fd = None
try:
lock_fd = open(lockfile, "w")
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
lock_fd.write(str(os.getpid()))
lock_fd.flush()
import boto3
from sqlalchemy import create_engine
from sqlalchemy.orm import Session as SASession
from ..lib.karaoke import process_karaoke
engine = create_engine(db_url)
session = SASession(bind=engine)
s3 = boto3.session.Session().client(
"s3",
region_name=s3_region,
endpoint_url=s3_endpoint,
aws_access_key_id=s3_access,
aws_secret_access_key=s3_secret,
)
log.info("On-demand karaoke: product=%s key=%s", product_id, s3_key)
sizes = process_karaoke(
s3, bucket, s3_key, s3_path, is_video, extension,
public_key=pk, secret_key=sk,
)
if sizes:
product = session.get(Product, product_id)
if product:
track_ext = extension 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
session.add(product)
session.commit()
product.update_s3_acls(s3, bucket)
log.info("On-demand karaoke done: product=%s inst=%dB vox=%dB",
product_id, sizes["instrumentals"], sizes["vocals"])
if has_mirror:
from ..models.shop import Shop as ShopModel
shop_obj = session.get(ShopModel, shop_id)
if shop_obj:
from ..lib.s3_mirror import mirror_keys_async
mirror_keys_async(
s3, bucket,
[f"{s3_path}/instrumentals", f"{s3_path}/vocals"],
shop_obj,
)
else:
log.warning("On-demand karaoke failed: product=%s", product_id)
session.close()
engine.dispose()
except (IOError, OSError):
pass
except Exception:
log.exception("On-demand karaoke child failed: product=%s", product_id)
finally:
if lock_fd:
try:
lock_fd.close()
os.unlink(lockfile)
except OSError:
pass
os._exit(0)
@view_config(route_name="discovery_ring_json", renderer="json")
def discovery_ring_json(request):
"""Public JSON endpoint to inspect the discovery ring for a shop."""