diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index 7d9f88f..e167be8 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -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") diff --git a/make_post_sell/static/css/common.css b/make_post_sell/static/css/common.css index 4c126f5..43a1edb 100644 --- a/make_post_sell/static/css/common.css +++ b/make_post_sell/static/css/common.css @@ -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; diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js index 8fd42e8..0c195a9 100644 --- a/make_post_sell/static/js/watch.js +++ b/make_post_sell/static/js/watch.js @@ -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 @@ + '' + '' + '' - + '' + + '' + '' + '' + '' @@ -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; diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 index bd9b432..9ea4664 100644 --- a/make_post_sell/templates/content.j2 +++ b/make_post_sell/templates/content.j2 @@ -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" %} -
+
@@ -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" %} -
+
{% if "thumbnail1" in product.extensions %} diff --git a/make_post_sell/templates/snippets/related_content.j2 b/make_post_sell/templates/snippets/related_content.j2 index 2d5642b..92cb4fd 100644 --- a/make_post_sell/templates/snippets/related_content.j2 +++ b/make_post_sell/templates/snippets/related_content.j2 @@ -29,7 +29,7 @@ - +
diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 320ae3a..193cadd 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -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( diff --git a/make_post_sell/views/content.py b/make_post_sell/views/content.py index fbfcd73..2495345 100644 --- a/make_post_sell/views/content.py +++ b/make_post_sell/views/content.py @@ -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, } diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py index b144f2d..a1e9cf7 100644 --- a/make_post_sell/views/watch.py +++ b/make_post_sell/views/watch.py @@ -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."""