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
This commit is contained in:
parent
fd33376cb7
commit
12f738d480
10 changed files with 979 additions and 41 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = '<h3>Up Next</h3><p>No more items</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<h3>Up Next</h3>';
|
||||
related.forEach(function(item) {
|
||||
html += '<a href="' + item.url + '" class="related-content-item" data-watch-id="' + item.id + '">';
|
||||
if (item.thumbnail_url) {
|
||||
html += '<img src="' + item.thumbnail_url + '" />';
|
||||
}
|
||||
html += '<span>' + escapeHtml(item.title) + '</span>';
|
||||
html += '</a>';
|
||||
html += '<button class="queue-add-btn js-only" data-product-id="' + item.id + '" data-title="' + escapeHtml(item.title) + '" data-url="' + item.url + '" data-media-type="' + (item.media_type || '') + '" data-thumbnail="' + (item.thumbnail_url || '') + '">+</button>';
|
||||
});
|
||||
|
||||
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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>
|
||||
<style>.js-only {display: none;}</style>
|
||||
</noscript>
|
||||
{% include 'snippets/ribbon.j2' %}
|
||||
<section class="main">
|
||||
|
||||
|
|
|
|||
|
|
@ -32,24 +32,59 @@
|
|||
|
||||
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}">
|
||||
|
||||
{% if request.shop.watch_mode_enabled %}<div class="watch-left">{% endif %}
|
||||
{% if request.shop.watch_mode_enabled %}<div class="watch-left" data-watch-product-id="{{ product.id }}">{% endif %}
|
||||
|
||||
<div class="product-images">
|
||||
<h1>{{ product.title }} <span class="subtitle-text">uploaded to <a href="{{ product.shop.absolute_about_url(request) }}" rel="nofollow" class="shop-theme-link-color">{{ product.shop.name }}</a></span></h1>
|
||||
|
||||
{% 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" %}
|
||||
<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>
|
||||
<div id="watch-media-container">
|
||||
<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>
|
||||
</div>
|
||||
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
||||
<img id="countdown-thumb" class="countdown-thumb" />
|
||||
<div class="countdown-text">
|
||||
<span id="countdown-title">Up next: ...</span>
|
||||
<span id="countdown-number">14</span>
|
||||
</div>
|
||||
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
|
||||
<button id="countdown-cancel" class="mps-button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
|
||||
</noscript>
|
||||
{% 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" %}
|
||||
<div id="watch-media-container">
|
||||
<div class="watch-audio-container">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
|
||||
{% endif %}
|
||||
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
|
||||
</div>
|
||||
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
||||
<img id="countdown-thumb" class="countdown-thumb" />
|
||||
<div class="countdown-text">
|
||||
<span id="countdown-title">Up next: ...</span>
|
||||
<span id="countdown-number">14</span>
|
||||
</div>
|
||||
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
|
||||
<button id="countdown-cancel" class="mps-button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<a href="{{ watch_audio_url }}" target="_blank">Listen</a>
|
||||
</noscript>
|
||||
{% 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" %}
|
||||
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ video_url }}')">
|
||||
|
|
@ -60,12 +95,12 @@
|
|||
<a href="{{ video_url }}" target="_blank">Open video</a>
|
||||
</noscript>
|
||||
<div class="video-click-to-play">click ▶ to play</div>
|
||||
{% else %}
|
||||
{% else %}
|
||||
{# Non-video: click to open in new window #}
|
||||
<a target="_blank" href="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/product?ts={{ product.updated_timestamp }}">
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<br/>
|
||||
</div>
|
||||
|
|
@ -111,6 +146,10 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="watch-queue" class="js-only" style="display:none">
|
||||
<h4>Queue</h4>
|
||||
<div id="queue-items"></div>
|
||||
</div>
|
||||
{% include 'snippets/related_content.j2' %}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,13 +30,13 @@
|
|||
|
||||
<section class="two-column{% if request.shop.watch_mode_enabled %} watch-mode{% endif %}">
|
||||
|
||||
{% if request.shop.watch_mode_enabled %}<div class="watch-left">{% endif %}
|
||||
{% if request.shop.watch_mode_enabled %}<div class="watch-left" data-watch-product-id="{{ product.id }}">{% endif %}
|
||||
|
||||
<div class="product-images">
|
||||
<h1>{{ product.title }} <span class="subtitle-text">sold by <a href="{{ product.shop.absolute_about_url(request) }}" rel="nofollow" class="shop-theme-link-color">{{ product.shop.name }}</a></span></h1>
|
||||
|
||||
{% 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 %}
|
||||
<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>
|
||||
<div id="watch-media-container">
|
||||
<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>
|
||||
</div>
|
||||
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
||||
<img id="countdown-thumb" class="countdown-thumb" />
|
||||
<div class="countdown-text">
|
||||
<span id="countdown-title">Up next: ...</span>
|
||||
<span id="countdown-number">14</span>
|
||||
</div>
|
||||
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
|
||||
<button id="countdown-cancel" class="mps-button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<a href="{{ watch_video_url }}" target="_blank">Open video</a>
|
||||
</noscript>
|
||||
{% 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 %}
|
||||
<div id="watch-media-container">
|
||||
<div class="watch-audio-container">
|
||||
{% if "thumbnail1" in product.extensions %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main audio-cover" />
|
||||
{% endif %}
|
||||
<audio id="watch-audio" src="{{ watch_audio_url }}" autoplay controls></audio>
|
||||
</div>
|
||||
<div id="watch-countdown" class="watch-countdown js-only" style="display:none">
|
||||
<img id="countdown-thumb" class="countdown-thumb" />
|
||||
<div class="countdown-text">
|
||||
<span id="countdown-title">Up next: ...</span>
|
||||
<span id="countdown-number">14</span>
|
||||
</div>
|
||||
<button id="countdown-play-now" class="mps-button mps-button-blue">Play Now</button>
|
||||
<button id="countdown-cancel" class="mps-button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<noscript>
|
||||
<a href="{{ watch_audio_url }}" target="_blank">Listen</a>
|
||||
</noscript>
|
||||
{% 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" %}
|
||||
<div id="video-container-{{ product.id }}" class="video-thumbnail-container" onclick="playInline(this, '{{ preview_url }}')">
|
||||
|
|
@ -69,11 +108,11 @@
|
|||
</a>
|
||||
<div class="video-click-to-play">click ▶ to play</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% else %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ product.s3_path }}/thumbnail1?ts={{ product.updated_timestamp }}" class="product-main" />
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<br/>
|
||||
{% endif %}
|
||||
|
||||
{% for file_key in product.file_thumbnail_keys %}
|
||||
{% if file_key in product.s3_key_thumbnails %}
|
||||
|
|
@ -193,6 +232,10 @@
|
|||
{% endif %}
|
||||
<div>
|
||||
|
||||
<div id="watch-queue" class="js-only" style="display:none">
|
||||
<h4>Queue</h4>
|
||||
<div id="queue-items"></div>
|
||||
</div>
|
||||
{% include 'snippets/related_content.j2' %}
|
||||
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,17 @@
|
|||
<div class="related-content">
|
||||
<h3>Up Next</h3>
|
||||
{% for related in related_products %}
|
||||
<a href="{{ related.absolute_url(request) }}" class="related-content-item">
|
||||
<a href="{{ related.absolute_url(request) }}" class="related-content-item" data-watch-id="{{ related.id }}">
|
||||
{% if "thumbnail1" in related.extensions %}
|
||||
<img src="{{ request.app["bucket.secure_uploads.get_endpoint"] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}" />
|
||||
{% endif %}
|
||||
<span>{{ related.title }}</span>
|
||||
</a>
|
||||
<button class="queue-add-btn js-only"
|
||||
data-product-id="{{ related.id }}"
|
||||
data-title="{{ related.title }}"
|
||||
data-url="{{ related.absolute_url(request) }}"
|
||||
data-thumbnail="{% if 'thumbnail1' in related.extensions %}{{ request.app['bucket.secure_uploads.get_endpoint'] }}/{{ related.s3_path }}/thumbnail1?ts={{ related.updated_timestamp }}{% endif %}">+</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
114
make_post_sell/views/watch.py
Normal file
114
make_post_sell/views/watch.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue