From 12f738d480729bc6acbcf7da5f7ff76772b9c203 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 8 Feb 2026 08:19:07 -0500 Subject: [PATCH] Watch mode v2: continuous playback with crossfade and queue Rewrite watch mode from basic autoplay into a full SPA media controller: - Fix get_related_products() with 3-tier fallback (stem overlap, same media type, recency) so orphan products still show recommendations - Add /watch/{id}/json endpoint for SPA navigation without page refresh - Rewrite watch.js: SPA fetch+pushState, 14-second countdown timer, DJ crossfade with dual media elements, queue system with sessionStorage - Add audio support in watch mode (album art + audio player) - Add queue "+" buttons to related content sidebar - Add noscript/js-only progressive enhancement pattern - Preload next media for instant transitions --- make_post_sell/models/product.py | 49 +- make_post_sell/routes.py | 1 + make_post_sell/static/js/watch.js | 547 +++++++++++++++++- make_post_sell/templates/base.j2 | 3 + make_post_sell/templates/content.j2 | 55 +- make_post_sell/templates/product.j2 | 59 +- .../templates/snippets/related_content.j2 | 7 +- make_post_sell/tests/test_functional.py | 56 ++ make_post_sell/tests/test_models.py | 129 ++++- make_post_sell/views/watch.py | 114 ++++ 10 files changed, 979 insertions(+), 41 deletions(-) create mode 100644 make_post_sell/views/watch.py diff --git a/make_post_sell/models/product.py b/make_post_sell/models/product.py index 0d709ae..3eb1dae 100644 --- a/make_post_sell/models/product.py +++ b/make_post_sell/models/product.py @@ -698,14 +698,18 @@ def tokenize_and_stem(text): def get_related_products(product, limit=8): - """Find related products in same shop by stemmed title+description overlap.""" + """Find related products in same shop using tiered fallback: + 1. Stem overlap (highest score first) + 2. Same media type (newest first) + 3. Any remaining public product (newest first) + """ current_stems = tokenize_and_stem( (product.title or '') + ' ' + (product.description or '') ) - if not current_stems: - return [] + current_ext = product.extensions.get("product", "") + current_media = get_media_type(current_ext) - candidates = [] + scored = [] for other in product.shop.products: if other.id == product.id: continue @@ -714,9 +718,36 @@ def get_related_products(product, limit=8): other_stems = tokenize_and_stem( (other.title or '') + ' ' + (other.description or '') ) - score = len(current_stems & other_stems) - if score > 0: - candidates.append((score, other)) + score = len(current_stems & other_stems) if current_stems else 0 + scored.append((score, other)) - candidates.sort(key=lambda x: x[0], reverse=True) - return [p for _, p in candidates[:limit]] + # Deterministic sort: highest score, newest, then id tiebreak + scored.sort(key=lambda x: (-x[0], -x[1].created_timestamp, str(x[1].id))) + + results = [] + result_ids = set() + + # Tier 1: stem matches (score > 0) + for s, p in scored: + if s > 0 and len(results) < limit: + results.append(p) + result_ids.add(p.id) + + # Tier 2: same media type + if current_media and len(results) < limit: + for _, p in scored: + if p.id not in result_ids and get_media_type(p.extensions.get("product", "")) == current_media: + results.append(p) + result_ids.add(p.id) + if len(results) >= limit: + break + + # Tier 3: any remaining public product + for _, p in scored: + if p.id not in result_ids: + results.append(p) + result_ids.add(p.id) + if len(results) >= limit: + break + + return results[:limit] diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py index dd2d3aa..4092935 100644 --- a/make_post_sell/routes.py +++ b/make_post_sell/routes.py @@ -170,6 +170,7 @@ def includeme(config): # media player routes 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("random", "/random") config.add_route("tv", "/tv") diff --git a/make_post_sell/static/js/watch.js b/make_post_sell/static/js/watch.js index 38ef6df..324e443 100644 --- a/make_post_sell/static/js/watch.js +++ b/make_post_sell/static/js/watch.js @@ -1,29 +1,548 @@ (function() { - var video = document.getElementById('watch-video'); - if (!video) return; + // Find the active media element (video or audio) + var activeMedia = document.getElementById('watch-video') || document.getElementById('watch-audio'); + if (!activeMedia) return; - // Try autoplay with sound (works when user navigated here via click) - var playPromise = video.play(); + // SPA state + var standbyMedia = null; + var preloadedData = null; + var queue = []; + var countdownTimer = null; + var crossfadeTimer = null; + var COUNTDOWN_SECONDS = 14; + var CROSSFADE_DURATION = 3000; + var CROSSFADE_STEPS = 60; + + // Restore queue from sessionStorage + try { + var savedQueue = sessionStorage.getItem('watchQueue'); + if (savedQueue) queue = JSON.parse(savedQueue); + } catch (e) {} + + // --- Autoplay with sound (existing behavior) --- + var playPromise = activeMedia.play(); if (playPromise !== undefined) { playPromise.catch(function() { - // Browser blocked autoplay with sound — try muted - video.muted = true; - video.play().then(function() { - // Playing muted — show unmute button - showUnmuteOverlay(video); - }).catch(function() { - // Even muted autoplay blocked — user needs to click play - }); + activeMedia.muted = true; + activeMedia.play().then(function() { + showUnmuteOverlay(activeMedia); + }).catch(function() {}); }); } - function showUnmuteOverlay(video) { + function showUnmuteOverlay(media) { var btn = document.querySelector('.unmute-overlay'); if (!btn) return; btn.style.display = 'block'; btn.addEventListener('click', function() { - video.muted = false; + media.muted = false; btn.style.display = 'none'; }); } + + // --- Create standby media element --- + function createStandbyMedia(type) { + var el = document.createElement(type === 'audio' ? 'audio' : 'video'); + el.preload = 'auto'; + el.style.display = 'none'; + var container = document.getElementById('watch-media-container'); + if (container) container.appendChild(el); + return el; + } + + // Determine current media type from the active element + var currentMediaType = activeMedia.tagName.toLowerCase(); + standbyMedia = createStandbyMedia(currentMediaType); + + // --- Queue management --- + function saveQueue() { + try { + sessionStorage.setItem('watchQueue', JSON.stringify(queue)); + } catch (e) {} + } + + function addToQueue(item) { + queue.push(item); + saveQueue(); + renderQueue(); + } + + function removeFromQueue(index) { + queue.splice(index, 1); + saveQueue(); + renderQueue(); + } + + function renderQueue() { + var container = document.getElementById('watch-queue'); + var items = document.getElementById('queue-items'); + if (!container || !items) return; + + if (queue.length === 0) { + container.style.display = 'none'; + return; + } + + container.style.display = 'block'; + items.innerHTML = ''; + + queue.forEach(function(item, i) { + var div = document.createElement('div'); + div.className = 'queue-item'; + + var titleSpan = document.createElement('span'); + titleSpan.className = 'queue-item-title'; + titleSpan.textContent = item.title; + titleSpan.style.cursor = 'pointer'; + titleSpan.addEventListener('click', function() { + // Play this item immediately, remove from queue + var playItem = queue.splice(i, 1)[0]; + saveQueue(); + renderQueue(); + fetchAndNavigate(playItem.id); + }); + + var removeBtn = document.createElement('button'); + removeBtn.className = 'queue-remove-btn'; + removeBtn.textContent = '\u00d7'; + removeBtn.title = 'Remove from queue'; + removeBtn.addEventListener('click', function() { + removeFromQueue(i); + }); + + div.appendChild(titleSpan); + div.appendChild(removeBtn); + items.appendChild(div); + }); + } + + // --- Fetch watch JSON --- + function fetchWatchData(productId) { + return fetch('/watch/' + productId + '/json', { + headers: {'X-Requested-With': 'XMLHttpRequest'} + }).then(function(r) { + if (!r.ok) throw new Error('Watch fetch failed'); + return r.json(); + }); + } + + // --- SPA navigation --- + function fetchAndNavigate(productId) { + cancelCountdown(); + fetchWatchData(productId).then(function(data) { + transitionTo(data); + }).catch(function() { + // Fallback: hard navigate + window.location.href = '/watch/' + productId + '/json'; + }); + } + + function transitionTo(data) { + var sameType = (data.media_type === currentMediaType); + + if (sameType) { + crossfade(data); + } else { + hardSwap(data); + } + } + + // --- Crossfade --- + function crossfade(data) { + if (crossfadeTimer) clearInterval(crossfadeTimer); + + // Ensure standby matches media type + if (standbyMedia.tagName.toLowerCase() !== data.media_type) { + hardSwap(data); + return; + } + + standbyMedia.src = data.media_url; + standbyMedia.volume = 0; + standbyMedia.currentTime = 0; + + var standbyPlayPromise = standbyMedia.play(); + if (standbyPlayPromise !== undefined) { + standbyPlayPromise.catch(function() { + // Crossfade failed, hard swap + hardSwap(data); + return; + }); + } + + var step = 0; + var interval = CROSSFADE_DURATION / CROSSFADE_STEPS; + + crossfadeTimer = setInterval(function() { + step++; + try { + activeMedia.volume = Math.max(0, 1 - step / CROSSFADE_STEPS); + standbyMedia.volume = Math.min(1, step / CROSSFADE_STEPS); + } catch (e) {} + + if (step === Math.floor(CROSSFADE_STEPS / 2)) { + updatePageContent(data); + } + + if (step >= CROSSFADE_STEPS) { + clearInterval(crossfadeTimer); + crossfadeTimer = null; + activeMedia.pause(); + activeMedia.style.display = 'none'; + standbyMedia.style.display = ''; + + // Swap references + var tmp = activeMedia; + activeMedia = standbyMedia; + standbyMedia = tmp; + + // Apply correct attributes + activeMedia.id = currentMediaType === 'video' ? 'watch-video' : 'watch-audio'; + activeMedia.controls = true; + activeMedia.className = currentMediaType === 'video' ? 'product-main' : ''; + + setupMediaEvents(); + preloadNext(); + } + }, interval); + } + + // --- Hard swap (different media types or fallback) --- + function hardSwap(data) { + activeMedia.pause(); + + var container = document.getElementById('watch-media-container'); + if (!container) return; + + // Remove old standby + if (standbyMedia && standbyMedia.parentNode) { + standbyMedia.parentNode.removeChild(standbyMedia); + } + + // Determine new media type + var newType = data.media_type; + currentMediaType = newType; + + if (newType === 'audio') { + // Replace with audio container + container.innerHTML = ''; + + // Show album art if available + if (data.thumbnail_url) { + var img = document.createElement('img'); + img.src = data.thumbnail_url; + img.className = 'product-main audio-cover'; + container.appendChild(img); + } + + var audio = document.createElement('audio'); + audio.id = 'watch-audio'; + audio.src = data.media_url; + audio.autoplay = true; + audio.controls = true; + container.appendChild(audio); + + activeMedia = audio; + } else { + // Replace with video + container.innerHTML = ''; + + var videoWrap = document.createElement('div'); + videoWrap.className = 'watch-video-container'; + + var video = document.createElement('video'); + video.id = 'watch-video'; + video.src = data.media_url; + video.autoplay = true; + video.controls = true; + video.playsInline = true; + video.className = 'product-main'; + videoWrap.appendChild(video); + + var unmuteBtn = document.createElement('button'); + unmuteBtn.className = 'unmute-overlay'; + unmuteBtn.textContent = 'Tap to unmute'; + videoWrap.appendChild(unmuteBtn); + + container.appendChild(videoWrap); + activeMedia = video; + } + + // Create new standby + standbyMedia = createStandbyMedia(newType); + + // Re-add countdown overlay + var countdown = document.getElementById('watch-countdown'); + if (countdown) container.appendChild(countdown); + + updatePageContent(data); + setupMediaEvents(); + + // Try autoplay + var p = activeMedia.play(); + if (p !== undefined) { + p.catch(function() { + activeMedia.muted = true; + activeMedia.play().then(function() { + showUnmuteOverlay(activeMedia); + }).catch(function() {}); + }); + } + + preloadNext(); + } + + // --- Update page content (title, description, related, URL) --- + function updatePageContent(data) { + // Update page title + document.title = data.title; + + // Update the heading + var h1 = document.querySelector('.product-images h1, .watch-audio-container + h1, .product-images + h1'); + if (!h1) { + // Try content.j2 layout where h1 is inside product-images + h1 = document.querySelector('h1'); + } + // We only update the text node of the title, preserving the subtitle span + if (h1) { + var titleNode = h1.firstChild; + if (titleNode && titleNode.nodeType === Node.TEXT_NODE) { + titleNode.textContent = data.title + ' '; + } + } + + // Update description + var descSection = document.querySelector('.product-description section'); + if (descSection) { + descSection.innerHTML = data.description_html; + } + + // Update related content sidebar + updateRelated(data.related); + + // Update URL + if (data.canonical_url) { + history.pushState({productId: data.product_id}, data.title, data.canonical_url); + } + + // Re-render queue + renderQueue(); + } + + function updateRelated(related) { + var container = document.querySelector('.related-content'); + if (!container) { + // Create if it doesn't exist + var productRight = document.querySelector('.product-right'); + if (!productRight) return; + container = document.createElement('div'); + container.className = 'related-content'; + productRight.appendChild(container); + } + + if (!related || related.length === 0) { + container.innerHTML = '

Up Next

No more items

'; + return; + } + + var html = '

Up Next

'; + related.forEach(function(item) { + html += ''; + if (item.thumbnail_url) { + html += ''; + } + html += '' + escapeHtml(item.title) + ''; + html += ''; + html += ''; + }); + + container.innerHTML = html; + } + + function escapeHtml(text) { + var div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // --- Countdown timer --- + function getNextItem() { + if (queue.length > 0) return queue[0]; + // Get first related item + var firstRelated = document.querySelector('.related-content-item[data-watch-id]'); + if (firstRelated) { + return { + id: firstRelated.getAttribute('data-watch-id'), + title: firstRelated.querySelector('span') ? firstRelated.querySelector('span').textContent : '', + thumbnail_url: firstRelated.querySelector('img') ? firstRelated.querySelector('img').src : null, + url: firstRelated.getAttribute('href') + }; + } + return null; + } + + function startCountdown() { + var next = getNextItem(); + if (!next) return; + + var overlay = document.getElementById('watch-countdown'); + if (!overlay) return; + + var thumb = document.getElementById('countdown-thumb'); + var titleEl = document.getElementById('countdown-title'); + var numberEl = document.getElementById('countdown-number'); + + if (thumb) { + if (next.thumbnail_url) { + thumb.src = next.thumbnail_url; + thumb.style.display = ''; + } else { + thumb.style.display = 'none'; + } + } + if (titleEl) titleEl.textContent = 'Up next: ' + next.title; + if (numberEl) numberEl.textContent = COUNTDOWN_SECONDS; + + overlay.style.display = ''; + + var remaining = COUNTDOWN_SECONDS; + countdownTimer = setInterval(function() { + remaining--; + if (numberEl) numberEl.textContent = remaining; + if (remaining <= 0) { + cancelCountdown(); + navigateToNext(next); + } + }, 1000); + } + + function cancelCountdown() { + if (countdownTimer) { + clearInterval(countdownTimer); + countdownTimer = null; + } + var overlay = document.getElementById('watch-countdown'); + if (overlay) overlay.style.display = 'none'; + } + + function navigateToNext(next) { + var productId; + if (next && next.id) { + productId = next.id; + // If from queue, remove it + if (queue.length > 0 && queue[0].id === productId) { + queue.shift(); + saveQueue(); + renderQueue(); + } + } else { + return; + } + + // Use preloaded data if available and matches + if (preloadedData && preloadedData.product_id === productId) { + transitionTo(preloadedData); + preloadedData = null; + } else { + fetchAndNavigate(productId); + } + } + + // --- Preload next media --- + function preloadNext() { + var next = getNextItem(); + if (!next || !next.id) return; + + fetchWatchData(next.id).then(function(data) { + preloadedData = data; + if (standbyMedia && data.media_url) { + standbyMedia.src = data.media_url; + standbyMedia.preload = 'auto'; + } + }).catch(function() { + preloadedData = null; + }); + } + + // --- Media event setup --- + function setupMediaEvents() { + activeMedia.addEventListener('ended', function() { + startCountdown(); + }); + + activeMedia.addEventListener('loadedmetadata', function() { + preloadNext(); + }); + } + + // Initial event setup + setupMediaEvents(); + + // --- Event delegation for SPA link interception --- + document.addEventListener('click', function(e) { + // Queue add button + var queueBtn = e.target.closest('.queue-add-btn'); + if (queueBtn) { + e.preventDefault(); + e.stopPropagation(); + addToQueue({ + id: queueBtn.getAttribute('data-product-id'), + title: queueBtn.getAttribute('data-title'), + url: queueBtn.getAttribute('data-url'), + media_type: queueBtn.getAttribute('data-media-type'), + thumbnail_url: queueBtn.getAttribute('data-thumbnail') + }); + return; + } + + // Related content links — SPA intercept + var link = e.target.closest('.related-content-item[data-watch-id]'); + if (link) { + e.preventDefault(); + var productId = link.getAttribute('data-watch-id'); + fetchAndNavigate(productId); + return; + } + + // Queue item clicks are handled in renderQueue + + // Countdown play now + if (e.target.id === 'countdown-play-now' || e.target.closest('#countdown-play-now')) { + e.preventDefault(); + cancelCountdown(); + var next = getNextItem(); + if (next) navigateToNext(next); + return; + } + + // Countdown cancel + if (e.target.id === 'countdown-cancel' || e.target.closest('#countdown-cancel')) { + e.preventDefault(); + cancelCountdown(); + return; + } + }); + + // --- Browser back/forward --- + window.addEventListener('popstate', function(e) { + if (e.state && e.state.productId) { + fetchAndNavigate(e.state.productId); + } + }); + + // Store initial state + var initialProductId = document.querySelector('[data-watch-product-id]'); + if (initialProductId) { + history.replaceState( + {productId: initialProductId.getAttribute('data-watch-product-id')}, + document.title, + window.location.href + ); + } + + // Render queue on load + renderQueue(); + + // Preload next on initial load + preloadNext(); })(); diff --git a/make_post_sell/templates/base.j2 b/make_post_sell/templates/base.j2 index 535ae9d..0b69224 100644 --- a/make_post_sell/templates/base.j2 +++ b/make_post_sell/templates/base.j2 @@ -82,6 +82,9 @@ + {% include 'snippets/ribbon.j2' %}
diff --git a/make_post_sell/templates/content.j2 b/make_post_sell/templates/content.j2 index 5e8bd11..130b69c 100644 --- a/make_post_sell/templates/content.j2 +++ b/make_post_sell/templates/content.j2 @@ -32,24 +32,59 @@
-{% if request.shop.watch_mode_enabled %}
{% endif %} +{% if request.shop.watch_mode_enabled %}
{% endif %}

{{ product.title }} uploaded to {{ product.shop.name }}

- {% if "thumbnail1" in product.extensions %} {% set video_extensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"] %} + {% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %} {% 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.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %} -
- - +
+
+ + +
+
- {% elif product.extensions.get("product") in video_extensions %} + {% 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.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %} +
+
+ {% if "thumbnail1" in product.extensions %} + + {% endif %} + +
+ +
+ + {% elif "thumbnail1" in product.extensions %} + {% if product.extensions.get("product") in video_extensions %} {# Video: play button overlay, click to play inline #} {% set video_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/product" %}
@@ -60,12 +95,12 @@ Open video
click ▶ to play
- {% else %} + {% else %} {# Non-video: click to open in new window #} - {% endif %} + {% endif %} {% endif %}
@@ -111,6 +146,10 @@ {% endif %}
{% endif %} + {% include 'snippets/related_content.j2' %}
diff --git a/make_post_sell/templates/product.j2 b/make_post_sell/templates/product.j2 index 3902c1a..87cb7ed 100644 --- a/make_post_sell/templates/product.j2 +++ b/make_post_sell/templates/product.j2 @@ -30,13 +30,13 @@
-{% if request.shop.watch_mode_enabled %}
{% endif %} +{% if request.shop.watch_mode_enabled %}
{% endif %}

{{ product.title }} sold by {{ product.shop.name }}

- {% if "thumbnail1" in product.extensions %} {% set video_extensions = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"] %} + {% set audio_extensions = ["mp3", "wav", "ogg", "m4a", "flac", "aac", "opus"] %} {% if request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in video_extensions %} {# Watch mode: direct video render with autoplay #} {% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %} @@ -44,14 +44,53 @@ {% else %} {% set watch_video_url = signed_get_object_url %} {% endif %} -
- - +
+
+ + +
+
- {% elif signed_get_object_url and product.extensions.get("product") in video_extensions %} + {% elif request.shop.watch_mode_enabled and signed_get_object_url and product.extensions.get("product") in audio_extensions %} + {# Watch mode: audio with album art #} + {% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %} + {% set watch_audio_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %} + {% else %} + {% set watch_audio_url = signed_get_object_url %} + {% endif %} +
+
+ {% if "thumbnail1" in product.extensions %} + + {% endif %} + +
+ +
+ + {% elif "thumbnail1" in product.extensions %} + {% if signed_get_object_url and product.extensions.get("product") in video_extensions %} {% if request.popout_player_enabled and product.visibility == 1 and "preview" in product.extensions %} {% set preview_url = request.app["bucket.secure_uploads.get_endpoint"] ~ "/" ~ product.s3_path ~ "/preview" %}
@@ -69,11 +108,11 @@
click ▶ to play
{% endif %} - {% else %} + {% else %} + {% endif %} {% endif %}
- {% endif %} {% for file_key in product.file_thumbnail_keys %} {% if file_key in product.s3_key_thumbnails %} @@ -193,6 +232,10 @@ {% endif %}
+ {% include 'snippets/related_content.j2' %}
diff --git a/make_post_sell/templates/snippets/related_content.j2 b/make_post_sell/templates/snippets/related_content.j2 index afecf4c..66d6ac5 100644 --- a/make_post_sell/templates/snippets/related_content.j2 +++ b/make_post_sell/templates/snippets/related_content.j2 @@ -2,12 +2,17 @@ {% endif %} diff --git a/make_post_sell/tests/test_functional.py b/make_post_sell/tests/test_functional.py index 48491d3..558ccbb 100644 --- a/make_post_sell/tests/test_functional.py +++ b/make_post_sell/tests/test_functional.py @@ -2564,6 +2564,62 @@ class AuthenticatedFunctionalTests(FunctionalTests): res = res.follow() self.assertIn(b"Invalid unsubscribe link", res.body) + def test_watch_json_requires_watch_mode(self): + """Test that /watch/{id}/json returns 404 when watch mode is disabled.""" + shop = self._create_shop_helper( + shop_params={ + **self.shop1_params, + "name": "watch-json-shop", + } + ) + + # Create a product + redirect_res = self.testapp.post( + f"/p/new?shop_id={shop.id}", self.product1_params + ) + res = redirect_res.follow() + self.assertIn("Great, next you may upload files.", res.body.decode()) + + products = get_all_products(self.dbsession).all() + product = products[0] + + # Watch mode is off by default — should get 404 + res = self.testapp.get(f"/watch/{product.id}/json", status=404) + self.assertIn("Watch mode not enabled", res.json["error"]) + + def test_watch_json_with_watch_mode_enabled(self): + """Test that /watch/{id}/json returns error for product without media file.""" + shop = self._create_shop_helper( + shop_params={ + **self.shop1_params, + "name": "watch-json-enabled-shop", + } + ) + + # Enable watch mode + self.testapp.post( + f"/s/{shop.id}/settings", + { + "form_section": "ribbon-settings", + "watch_mode": "1", + "submit": "Save Settings", + }, + status=302, + ) + + # Create a product (no media file uploaded) + redirect_res = self.testapp.post( + f"/p/new?shop_id={shop.id}", self.product1_params + ) + res = redirect_res.follow() + + products = get_all_products(self.dbsession).all() + product = products[0] + + # Product has no media file — should get 403 (no preview available) + res = self.testapp.get(f"/watch/{product.id}/json", status=403) + self.assertIn("No preview available", 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/tests/test_models.py b/make_post_sell/tests/test_models.py index 529806c..2c0111e 100644 --- a/make_post_sell/tests/test_models.py +++ b/make_post_sell/tests/test_models.py @@ -1977,7 +1977,7 @@ class TestStemmer(unittest.TestCase): self.assertLess(related.index(product4), related.index(product3)) def test_get_related_products_empty_description(self): - """Test with empty title/description.""" + """Test with empty title/description still returns results via fallback.""" from ..models.product import Product, get_related_products shop = Shop( @@ -1996,6 +1996,41 @@ class TestStemmer(unittest.TestCase): related = get_related_products(product) self.assertEqual(related, []) + def test_get_related_products_empty_description_with_others(self): + """Test orphan product (no stems) gets fallback results.""" + from ..models.product import Product, get_related_products + + shop = Shop( + "test-shop", + "555-555-5555", + "123 Test St", + "Test shop description", + ) + + product = Product("", "") + product.shop = shop + product.visibility = 1 + product.created_timestamp = 100 + + other1 = Product("Song One", "A great song") + other1.shop = shop + other1.visibility = 1 + other1.created_timestamp = 200 + + other2 = Product("Song Two", "Another song") + other2.shop = shop + other2.visibility = 1 + other2.created_timestamp = 300 + + all_products = [product, other1, other2] + with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products: + mock_products.return_value = all_products + related = get_related_products(product) + # Should return fallback results (tier 3: any public product) + self.assertEqual(len(related), 2) + self.assertIn(other1, related) + self.assertIn(other2, related) + def test_get_related_products_limit(self): """Test that limit parameter is respected.""" from ..models.product import Product, get_related_products @@ -2031,6 +2066,98 @@ class TestStemmer(unittest.TestCase): related = get_related_products(product1, limit=3) self.assertLessEqual(len(related), 3) + def test_get_related_products_same_media_type_fallback(self): + """Test tier 2: same media type fills remaining slots.""" + from ..models.product import Product, get_related_products + + shop = Shop( + "test-shop", + "555-555-5555", + "123 Test St", + "Test shop description", + ) + + def _make_product(title, desc, timestamp, ext=None): + p = Product(title, desc) + p.shop = shop + p.visibility = 1 + p.created_timestamp = timestamp + if ext: + p._file_metadata = {"extensions": {"product": ext}, "originals": {}} + return p + + # Product with no stem overlap to anything + product1 = _make_product("Xylophone Jam", "Unique xylophone music", 100, "mp3") + + # Same media type (audio) but no stem overlap + product2 = _make_product("Drums Solo", "Percussion performance", 200, "mp3") + + # Different media type (video) and no stem overlap + product3 = _make_product("Cat Video", "Funny cat footage", 300, "mp4") + + # Another audio with no stem overlap + product4 = _make_product("Bass Line", "Groovy bass track", 400, "wav") + + all_products = [product1, product2, product3, product4] + with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products: + mock_products.return_value = all_products + + related = get_related_products(product1) + # Should include all 3 others + self.assertEqual(len(related), 3) + + # Same media type (audio) should come before different type (video) + audio_indices = [] + video_indices = [] + for i, p in enumerate(related): + ext = p.extensions.get("product", "") + if ext in ("mp3", "wav"): + audio_indices.append(i) + elif ext == "mp4": + video_indices.append(i) + + # All audio items should come before video items + if audio_indices and video_indices: + self.assertLess(max(audio_indices), min(video_indices)) + + def test_get_related_products_deterministic(self): + """Test that results are deterministic (consistent ordering).""" + from ..models.product import Product, get_related_products + + shop = Shop( + "test-shop", + "555-555-5555", + "123 Test St", + "Test shop description", + ) + + product1 = Product("Guitar Music", "Guitar tutorial video") + product1.shop = shop + product1.visibility = 1 + product1.created_timestamp = 100 + + product2 = Product("Guitar Songs", "Guitar song collection") + product2.shop = shop + product2.visibility = 1 + product2.created_timestamp = 200 + + product3 = Product("Guitar Chords", "Guitar chord lessons") + product3.shop = shop + product3.visibility = 1 + product3.created_timestamp = 300 + + all_products = [product1, product2, product3] + with mock.patch.object(type(shop), 'products', new_callable=mock.PropertyMock) as mock_products: + mock_products.return_value = all_products + + # Run multiple times to verify determinism + related1 = get_related_products(product1) + related2 = get_related_products(product1) + self.assertEqual( + [p.title for p in related1], + [p.title for p in related2], + ) + class TestShopSubscription(unittest.TestCase): """Test ShopSubscription model.""" diff --git a/make_post_sell/views/watch.py b/make_post_sell/views/watch.py new file mode 100644 index 0000000..a993c5d --- /dev/null +++ b/make_post_sell/views/watch.py @@ -0,0 +1,114 @@ +from pyramid.view import view_config + +from ..models.product import get_media_type, get_related_products + + +@view_config(route_name="watch_json", renderer="json") +def watch_json(request): + """JSON endpoint for watch mode SPA navigation.""" + + product = request.product + + if not product: + request.response.status_int = 404 + return {"error": "Product not found"} + + if not request.shop or not request.shop.watch_mode_enabled: + request.response.status_int = 404 + return {"error": "Watch mode not enabled"} + + # Only allow public products (visibility == 1) + if product.visibility != 1: + request.response.status_int = 403 + return {"error": "Not publicly available"} + + # Determine which file to use + if product.is_sellable: + file_key = "preview" + if file_key not in product.extensions: + request.response.status_int = 403 + return {"error": "No preview available"} + else: + file_key = "product" + if file_key not in product.extensions: + request.response.status_int = 404 + return {"error": "No media file available"} + + # Get extension and detect media type + extension = product.extensions.get(file_key) + media_type = get_media_type(extension) + + if not media_type or media_type not in ("video", "audio"): + request.response.status_int = 400 + return {"error": "Not a playable media file"} + + # Generate presigned URL for media + bucket_name = request.app["bucket.secure_uploads"] + s3_key = f"{product.s3_path}/{file_key}" + + params = { + "Bucket": bucket_name, + "Key": s3_key, + } + + content_disposition = f'inline; filename="{product.title}.{extension}"' + content_type = product.get_content_type(file_key) + + if content_disposition: + params["ResponseContentDisposition"] = content_disposition + + if content_type: + params["ResponseContentType"] = content_type + + media_url = request.secure_uploads_client.generate_presigned_url( + ClientMethod="get_object", + Params=params, + ExpiresIn=900, + ) + + # Thumbnail URL + thumbnail_url = None + if "thumbnail1" in product.extensions: + thumbnail_url = ( + f"{request.app['bucket.secure_uploads.get_endpoint']}" + f"/{product.s3_path}/thumbnail1" + f"?ts={product.updated_timestamp}" + ) + + # Canonical URL (relative path) + p_or_c = "p" if product.is_sellable else "c" + canonical_url = f"/{p_or_c}/{product.id}/{product.slug}" + + # Related products + related = get_related_products(product) + related_data = [] + for r in related: + r_ext = r.extensions.get("product", "") + r_media = get_media_type(r_ext) + r_thumb = None + if "thumbnail1" in r.extensions: + r_thumb = ( + f"{request.app['bucket.secure_uploads.get_endpoint']}" + f"/{r.s3_path}/thumbnail1" + f"?ts={r.updated_timestamp}" + ) + r_p_or_c = "p" if r.is_sellable else "c" + related_data.append({ + "id": str(r.id), + "title": r.title, + "thumbnail_url": r_thumb, + "url": f"/{r_p_or_c}/{r.id}/{r.slug}", + "media_type": r_media, + }) + + return { + "product_id": str(product.id), + "title": product.title, + "media_url": media_url, + "media_type": media_type, + "thumbnail_url": thumbnail_url, + "description_html": product.description_html, + "canonical_url": canonical_url, + "is_sellable": product.is_sellable, + "related": related_data, + }