openvibers/openvibe.live Commits
Media requests: unblock thumbnails, refund dead requests, fix item layout
Changes
@@@ -6888,15 +6888,21 @@ textarea.form-input { resize: vertical; min-height: 60px; }.ch-media-section-label i { color: var(--accent); }.ch-media-now .ch-media-item { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); }.ch-media-queue { display: flex; flex-direction: column; gap: 8px; }--.ch-media-item { display: flex; align-items: center; gap: 12px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; }++.ch-media-item { display: flex; align-items: center; gap: 12px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; flex-wrap: wrap; }.ch-media-pos { width: 22px; text-align: center; font-weight: 700; color: var(--text-muted); flex-shrink: 0; }--.ch-media-thumb { width: 72px; height: 48px; border-radius: 6px; overflow: hidden; flex-shrink: 0; background: var(--bg-primary); }--.ch-media-thumb img { width: 100%; height: 100%; object-fit: cover; }++.ch-media-thumb { width: 72px; height: 48px; border-radius: 6px; overflow: hidden; flex: 0 0 auto; background: var(--bg-primary); position: relative; }++.ch-media-thumb img { width: 100%; height: 100%; object-fit: cover; position: absolute; inset: 0; }.ch-media-thumb-ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--text-muted); }.ch-media-meta { min-width: 0; flex: 1; }--.ch-media-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ch-media-title {++ font-weight: 600;++ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;++ overflow: hidden; overflow-wrap: anywhere;++}++.ch-media-meta { flex: 1 1 180px; }.ch-media-sub { font-size: 0.82rem; }.ch-media-cost { margin-left: 6px; white-space: nowrap; }++.ch-media-problem { margin-top: 3px; font-size: 0.8rem; color: var(--live-red, #e05a5a); overflow-wrap: anywhere; }.ch-media-cost i { color: var(--accent); }/* Queue controls — shown to the streamer and to their channel mods. */@@@ -6908,7 +6914,8 @@ textarea.form-input { resize: vertical; min-height: 60px; }font-size: 0.85rem;}.ch-media-mod-bar > span:first-child { flex: 1; }--.ch-media-actions { display: flex; gap: 5px; flex-shrink: 0; }++/* The controls drop to their own full-width row rather than squeezing the title out. */++.ch-media-actions { display: flex; gap: 5px; flex: 0 0 auto; margin-left: auto; }.ch-media-actions .btn { padding: 5px 8px; font-size: 0.78rem; line-height: 1; }.ch-media-actions .btn.danger:hover { border-color: var(--live-red, #e05a5a); color: var(--live-red, #e05a5a); }@@@ -6926,10 +6933,15 @@ textarea.form-input { resize: vertical; min-height: 60px; }.ch-media-quote-price.ok { color: var(--success, #4fd48a); }.ch-media-quote-price.err { color: var(--live-red, #e05a5a); font-weight: 600; }.ch-media-quote-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }--@media (max-width: 640px) {++/* Container-relative, not viewport-relative: the channel tab is a narrow column even on++ a wide screen, which is why viewport breakpoints alone left the controls clipped. */++.ch-media-item > .ch-media-actions { min-width: 0; }++@media (max-width: 900px) {++ .ch-media-actions { width: 100%; justify-content: flex-end; margin-left: 0; }.ch-media-quote-actions { width: 100%; }-- .ch-media-quote-actions .btn { flex: 1; }-- .ch-media-actions { width: 100%; justify-content: flex-end; }++ .ch-media-quote-actions .btn { flex: 1 1 auto; }++ .ch-media-quote-meta { flex: 1 1 100%; }++ .ch-media-mod-bar > span:first-child { flex: 1 1 100%; }}@media (max-width: 640px) { .ch-tab { padding: 9px 11px; font-size: 0.84rem; } }@@@ -5071,9 +5071,18 @@ async function loadChannelMedia(username = currentChannelUsername) {</div>`;} catch { host.innerHTML = '<div class="ch-about-empty">Failed to load the media queue.</div>'; }}++// Thumbnails come from third-party CDNs and 404 often enough (maxresdefault does not++// exist for every video) that an unhandled failure leaves a broken-image glyph. The++// placeholder always sits underneath, and a failed image simply removes itself to reveal++// it — no HTML-in-an-attribute quoting to get wrong.++function _mediaThumbHTML(url) {++ const ph = '<div class="ch-media-thumb-ph"><i class="fa-solid fa-music"></i></div>';++ if (!url) return ph;++ return `${ph}<img src="${esc(url)}" alt="" loading="lazy" onerror="this.remove()">`;++}function _mediaItemHTML(m, pos) {const dur = m.duration_seconds ? formatDuration(m.duration_seconds) : '';-- const thumb = m.thumbnail_url ? `<img src="${esc(m.thumbnail_url)}" alt="" loading="lazy">` : '<div class="ch-media-thumb-ph"><i class="fa-solid fa-music"></i></div>';++ const thumb = _mediaThumbHTML(m.thumbnail_url);const cost = Number(m.cost || 0) > 0? `<span class="ch-media-cost"><i class="${_mediaCurrencyIcon(m.currency)}"></i> ${m.cost}</span>` : '';// Mods get the same controls as the streamer; the server re-checks permission on each.@@@ -5084,7 +5093,21 @@ function _mediaItemHTML(m, pos) {<button class="btn btn-xs btn-outline" title="Move down" onclick="_mediaQueueAction(${m.id}, 'down')"><i class="fa-solid fa-arrow-down"></i></button><button class="btn btn-xs btn-outline danger" title="Remove & refund" onclick="_mediaQueueAction(${m.id}, 'remove')"><i class="fa-solid fa-trash"></i></button></div>` : '';-- return `<div class="ch-media-item">${pos ? `<span class="ch-media-pos">${pos}</span>` : ''}<div class="ch-media-thumb">${thumb}</div><div class="ch-media-meta"><div class="ch-media-title">${esc(m.title || m.input || 'Media')}</div><div class="ch-media-sub muted">${dur ? dur + ' · ' : ''}requested by ${esc(m.username || 'someone')} ${cost}</div></div>${controls}</div>`;++ // A request that cannot play should say so where it sits, not look like a normal item.++ const problem = (m.download_status === 'failed' || m.status === 'failed')++ ? `<div class="ch-media-problem"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(_mediaShortError(m.last_error))}</div>` : '';++ return `<div class="ch-media-item">${pos ? `<span class="ch-media-pos">${pos}</span>` : ''}<div class="ch-media-thumb">${thumb}</div><div class="ch-media-meta"><div class="ch-media-title">${esc(m.title || m.input || 'Media')}</div><div class="ch-media-sub muted">${dur ? dur + ' · ' : ''}requested by ${esc(m.username || 'someone')} ${cost}</div>${problem}</div>${controls}</div>`;++}++// yt-dlp errors are paragraphs; the queue only has room for the part that matters.++function _mediaShortError(err) {++ const m = String(err || '').toLowerCase();++ if (m.includes('bot')) return 'YouTube blocked this on the server (sign-in check).';++ if (m.includes('private')) return 'This video is private.';++ if (m.includes('members-only')) return 'This video is members-only.';++ if (m.includes('age')) return 'This video is age-restricted.';++ if (m.includes('unavailable') || m.includes('not available')) return 'This video is unavailable.';++ if (m.includes('yt-dlp is not available')) return 'The server cannot play media right now.';++ return 'This media could not be prepared.';}// ── Queue management (streamer + channel mods) ───────────────@@@ -5138,7 +5161,7 @@ function _renderMediaQuote(q) {const el = document.getElementById('ch-media-quote');if (!el) return;const dur = q.duration_seconds ? formatDuration(q.duration_seconds) : 'unknown length';-- const thumb = q.thumbnail_url ? `<img src="${esc(q.thumbnail_url)}" alt="">` : '<div class="ch-media-thumb-ph"><i class="fa-solid fa-music"></i></div>';++ const thumb = _mediaThumbHTML(q.thumbnail_url);let priceLine, action;if (!q.allowed) {@@@ -220,7 +220,10 @@ app.use(helmet({// esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restreamstyleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com"],fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],-- imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com"],++ // i.ytimg.com / i.vimeocdn.com carry media-request thumbnails. Without them++ // every queued item renders as a broken image, which is what the media++ // request tab was doing for every YouTube link.++ imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com"],connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com"],// VODs/clips play from openvibe.media (the /api proxies 302 there), which may// itself redirect to presigned B2/R2 object-store URLs — all must be allowed@@@ -76,6 +76,20 @@ class MediaQueue {return Math.max(1, Number(settings.request_cost) || DEFAULTS.request_cost);}++ /**++ * Why this request cannot be priced, or null if it can.++ *++ * Per-minute pricing needs a duration, and yt-dlp returns none when YouTube blocks the++ * server. Falling back to the flat rate there would charge a number the viewer was++ * never shown, so an unpriceable request is refused outright instead.++ */++ unpriceableReason(settings, durationSeconds) {++ if (this.currencyOf(settings) === 'free') return null;++ if (settings.cost_mode !== 'per_minute') return null;++ if (Number.isFinite(durationSeconds) && durationSeconds > 0) return null;++ return 'This channel charges per minute, and the length of that link could not be read.';++ }++/** Normalised currency for a channel; defaults to OpenCoins as before. */currencyOf(settings) {const c = String((settings && settings.currency) || DEFAULTS.currency).toLowerCase();@@@ -171,6 +185,8 @@ class MediaQueue {// Price it, then charge in whichever currency this channel is configured for.const currency = this.currencyOf(settings);++ const unpriceable = this.unpriceableReason(settings, normalized.duration_seconds);++ if (unpriceable) throw new Error(unpriceable);// A free channel stores cost 0, so the queue does not display a price nobody paid.const cost = currency === 'free' ? 0 : this.calculateCost(settings, normalized.duration_seconds);if (cost > 0) {@@@ -219,6 +235,58 @@ class MediaQueue {return request;}++ /**++ * Is this extraction error permanent, or worth retrying later?++ *++ * A viewer who pays for a video that can never play should get their money back in++ * seconds, not have it sit `pending` at the top of the queue until the streamer++ * happens to open the player and try it. But a transient network blip must NOT burn++ * the request, so only unambiguously permanent failures refund here.++ */++ _isTerminalExtractionError(message) {++ const m = String(message || '').toLowerCase();++ return [++ "sign in to confirm you're not a bot",++ 'sign in to confirm you’re not a bot',++ 'video unavailable',++ 'private video',++ 'members-only',++ 'age-restricted',++ 'confirm your age',++ 'removed by the uploader',++ 'account associated with this video has been terminated',++ 'this video is not available',++ 'yt-dlp is not available',++ 'unsupported url',++ ].some((needle) => m.includes(needle));++ }++++ /**++ * Mark a request dead and refund it when the failure is permanent. Returns true if++ * the request was failed.++ */++ _failIfTerminal(requestId, message) {++ if (!this._isTerminalExtractionError(message)) return false;++ const request = db.getMediaRequestById(requestId);++ // Only refund something still queued — a played item keeps its charge.++ if (!request || (request.status !== 'pending' && request.status !== 'playing')) return false;++ console.warn(`[MediaQueue] Request ${requestId} cannot play, refunding: ${message}`);++ this.failRequest(requestId, this._viewerFacingError(message));++ return true;++ }++++ /** Turn a yt-dlp wall of text into one line a viewer can act on. */++ _viewerFacingError(message) {++ const m = String(message || '').toLowerCase();++ if (m.includes('bot')) return 'YouTube blocked this video on the server (sign-in check). Refunded.';++ if (m.includes('private')) return 'That video is private. Refunded.';++ if (m.includes('members-only')) return 'That video is members-only. Refunded.';++ if (m.includes('age')) return 'That video is age-restricted. Refunded.';++ if (m.includes('unavailable') || m.includes('not available')) return 'That video is unavailable. Refunded.';++ if (m.includes('yt-dlp is not available')) return 'The server cannot play media right now. Refunded.';++ return 'That media could not be played. Refunded.';++ }++/*** Extract a direct stream URL for a pending/playing request (background).* Updates the DB row when done.@@@ -243,6 +311,7 @@ class MediaQueue {download_status: 'failed',last_error: 'yt-dlp is not available on the server',});++ this._failIfTerminal(requestId, 'yt-dlp is not available on the server');return db.getMediaRequestById(requestId);}@@@ -271,13 +340,16 @@ class MediaQueue {try {return await this.downloadFileForRequest(requestId);} catch (downloadErr) {++ const combined = `Extraction failed: ${err.message}. Download fallback failed: ${downloadErr.message}`;db.updateMediaRequest(requestId, {stream_url: null,embed_url: null,download_status: 'failed',-- last_error: `Extraction failed: ${err.message}. Download fallback failed: ${downloadErr.message}`,++ last_error: combined,});-- this.broadcastQueueUpdate(request.streamer_id);++ // Refunds immediately when the video can never play, rather than leaving a++ // paid-for item stuck at the top of the queue.++ if (!this._failIfTerminal(requestId, combined)) this.broadcastQueueUpdate(request.streamer_id);return db.getMediaRequestById(requestId);}}@@@ -179,6 +179,7 @@ router.post('/quote', optionalAuth, async (req, res) => {const cost = currency === 'free' ? 0 : mediaQueue.calculateCost(settings, duration);const tooLong = duration > 0 && duration > maxDuration;++ const unpriceable = mediaQueue.unpriceableReason(settings, duration);// What the viewer can actually pay right now, so the UI can say "you have X".let balance = null;@@@ -199,10 +200,10 @@ router.post('/quote', optionalAuth, async (req, res) => {cost,balance,affordable: balance == null ? null : balance >= cost,-- allowed: !tooLong,++ allowed: !tooLong && !unpriceable,reason: tooLong? `That video is ${Math.floor(duration / 60)}m${duration % 60}s — this channel allows up to ${Math.floor(maxDuration / 60)}m.`-- : null,++ : (unpriceable || null),});} catch (err) {res.status(400).json({ error: err.message || 'Could not price that link' });
@@ -6888,15 +6888,21 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -6888,15 +6888,21 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.ch-media-section-label i { color: var(--accent); }
.ch-media-section-label i { color: var(--accent); }
.ch-media-now .ch-media-item { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
.ch-media-now .ch-media-item { border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 8%, transparent); }
.ch-media-queue { display: flex; flex-direction: column; gap: 8px; }
.ch-media-queue { display: flex; flex-direction: column; gap: 8px; }
-.ch-media-item { display: flex; align-items: center; gap: 12px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; }
+.ch-media-item { display: flex; align-items: center; gap: 12px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; flex-wrap: wrap; }
.ch-media-pos { width: 22px; text-align: center; font-weight: 700; color: var(--text-muted); flex-shrink: 0; }
.ch-media-pos { width: 22px; text-align: center; font-weight: 700; color: var(--text-muted); flex-shrink: 0; }
-.ch-media-thumb { width: 72px; height: 48px; border-radius: 6px; overflow: hidden; flex-shrink: 0; background: var(--bg-primary); }
-.ch-media-thumb img { width: 100%; height: 100%; object-fit: cover; }
+.ch-media-thumb { width: 72px; height: 48px; border-radius: 6px; overflow: hidden; flex: 0 0 auto; background: var(--bg-primary); position: relative; }
+.ch-media-thumb img { width: 100%; height: 100%; object-fit: cover; position: absolute; inset: 0; }
.ch-media-thumb-ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--text-muted); }
.ch-media-thumb-ph { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: var(--text-muted); }
.ch-media-meta { min-width: 0; flex: 1; }
.ch-media-meta { min-width: 0; flex: 1; }
-.ch-media-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ch-media-title {
+ font-weight: 600;
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
+ overflow: hidden; overflow-wrap: anywhere;
+}
+.ch-media-meta { flex: 1 1 180px; }
.ch-media-sub { font-size: 0.82rem; }
.ch-media-sub { font-size: 0.82rem; }
.ch-media-cost { margin-left: 6px; white-space: nowrap; }
.ch-media-cost { margin-left: 6px; white-space: nowrap; }
+.ch-media-problem { margin-top: 3px; font-size: 0.8rem; color: var(--live-red, #e05a5a); overflow-wrap: anywhere; }
.ch-media-cost i { color: var(--accent); }
.ch-media-cost i { color: var(--accent); }
/* Queue controls — shown to the streamer and to their channel mods. */
/* Queue controls — shown to the streamer and to their channel mods. */
@@ -6908,7 +6914,8 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -6908,7 +6914,8 @@ textarea.form-input { resize: vertical; min-height: 60px; }
font-size: 0.85rem;
font-size: 0.85rem;
}
}
.ch-media-mod-bar > span:first-child { flex: 1; }
.ch-media-mod-bar > span:first-child { flex: 1; }
-.ch-media-actions { display: flex; gap: 5px; flex-shrink: 0; }
+/* The controls drop to their own full-width row rather than squeezing the title out. */
+.ch-media-actions { display: flex; gap: 5px; flex: 0 0 auto; margin-left: auto; }
.ch-media-actions .btn { padding: 5px 8px; font-size: 0.78rem; line-height: 1; }
.ch-media-actions .btn { padding: 5px 8px; font-size: 0.78rem; line-height: 1; }
.ch-media-actions .btn.danger:hover { border-color: var(--live-red, #e05a5a); color: var(--live-red, #e05a5a); }
.ch-media-actions .btn.danger:hover { border-color: var(--live-red, #e05a5a); color: var(--live-red, #e05a5a); }
@@ -6926,10 +6933,15 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -6926,10 +6933,15 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.ch-media-quote-price.ok { color: var(--success, #4fd48a); }
.ch-media-quote-price.ok { color: var(--success, #4fd48a); }
.ch-media-quote-price.err { color: var(--live-red, #e05a5a); font-weight: 600; }
.ch-media-quote-price.err { color: var(--live-red, #e05a5a); font-weight: 600; }
.ch-media-quote-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.ch-media-quote-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
-@media (max-width: 640px) {
+/* Container-relative, not viewport-relative: the channel tab is a narrow column even on
+ a wide screen, which is why viewport breakpoints alone left the controls clipped. */
+.ch-media-item > .ch-media-actions { min-width: 0; }
+@media (max-width: 900px) {
+ .ch-media-actions { width: 100%; justify-content: flex-end; margin-left: 0; }
.ch-media-quote-actions { width: 100%; }
.ch-media-quote-actions { width: 100%; }
- .ch-media-quote-actions .btn { flex: 1; }
- .ch-media-actions { width: 100%; justify-content: flex-end; }
+ .ch-media-quote-actions .btn { flex: 1 1 auto; }
+ .ch-media-quote-meta { flex: 1 1 100%; }
+ .ch-media-mod-bar > span:first-child { flex: 1 1 100%; }
}
}
@media (max-width: 640px) { .ch-tab { padding: 9px 11px; font-size: 0.84rem; } }
@media (max-width: 640px) { .ch-tab { padding: 9px 11px; font-size: 0.84rem; } }
@@ -5071,9 +5071,18 @@ async function loadChannelMedia(username = currentChannelUsername) {
@@ -5071,9 +5071,18 @@ async function loadChannelMedia(username = currentChannelUsername) {
</div>`;
</div>`;
} catch { host.innerHTML = '<div class="ch-about-empty">Failed to load the media queue.</div>'; }
} catch { host.innerHTML = '<div class="ch-about-empty">Failed to load the media queue.</div>'; }
}
}
+// Thumbnails come from third-party CDNs and 404 often enough (maxresdefault does not
+// exist for every video) that an unhandled failure leaves a broken-image glyph. The
+// placeholder always sits underneath, and a failed image simply removes itself to reveal
+// it — no HTML-in-an-attribute quoting to get wrong.
+function _mediaThumbHTML(url) {
+ const ph = '<div class="ch-media-thumb-ph"><i class="fa-solid fa-music"></i></div>';
+ if (!url) return ph;
+ return `${ph}<img src="${esc(url)}" alt="" loading="lazy" onerror="this.remove()">`;
+}
function _mediaItemHTML(m, pos) {
function _mediaItemHTML(m, pos) {
const dur = m.duration_seconds ? formatDuration(m.duration_seconds) : '';
const dur = m.duration_seconds ? formatDuration(m.duration_seconds) : '';
- const thumb = m.thumbnail_url ? `<img src="${esc(m.thumbnail_url)}" alt="" loading="lazy">` : '<div class="ch-media-thumb-ph"><i class="fa-solid fa-music"></i></div>';
+ const thumb = _mediaThumbHTML(m.thumbnail_url);
const cost = Number(m.cost || 0) > 0
const cost = Number(m.cost || 0) > 0
? `<span class="ch-media-cost"><i class="${_mediaCurrencyIcon(m.currency)}"></i> ${m.cost}</span>` : '';
? `<span class="ch-media-cost"><i class="${_mediaCurrencyIcon(m.currency)}"></i> ${m.cost}</span>` : '';
// Mods get the same controls as the streamer; the server re-checks permission on each.
// Mods get the same controls as the streamer; the server re-checks permission on each.
@@ -5084,7 +5093,21 @@ function _mediaItemHTML(m, pos) {
@@ -5084,7 +5093,21 @@ function _mediaItemHTML(m, pos) {
<button class="btn btn-xs btn-outline" title="Move down" onclick="_mediaQueueAction(${m.id}, 'down')"><i class="fa-solid fa-arrow-down"></i></button>
<button class="btn btn-xs btn-outline" title="Move down" onclick="_mediaQueueAction(${m.id}, 'down')"><i class="fa-solid fa-arrow-down"></i></button>
<button class="btn btn-xs btn-outline danger" title="Remove & refund" onclick="_mediaQueueAction(${m.id}, 'remove')"><i class="fa-solid fa-trash"></i></button>
<button class="btn btn-xs btn-outline danger" title="Remove & refund" onclick="_mediaQueueAction(${m.id}, 'remove')"><i class="fa-solid fa-trash"></i></button>
</div>` : '';
</div>` : '';
- return `<div class="ch-media-item">${pos ? `<span class="ch-media-pos">${pos}</span>` : ''}<div class="ch-media-thumb">${thumb}</div><div class="ch-media-meta"><div class="ch-media-title">${esc(m.title || m.input || 'Media')}</div><div class="ch-media-sub muted">${dur ? dur + ' · ' : ''}requested by ${esc(m.username || 'someone')} ${cost}</div></div>${controls}</div>`;
+ // A request that cannot play should say so where it sits, not look like a normal item.
+ const problem = (m.download_status === 'failed' || m.status === 'failed')
+ ? `<div class="ch-media-problem"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(_mediaShortError(m.last_error))}</div>` : '';
+ return `<div class="ch-media-item">${pos ? `<span class="ch-media-pos">${pos}</span>` : ''}<div class="ch-media-thumb">${thumb}</div><div class="ch-media-meta"><div class="ch-media-title">${esc(m.title || m.input || 'Media')}</div><div class="ch-media-sub muted">${dur ? dur + ' · ' : ''}requested by ${esc(m.username || 'someone')} ${cost}</div>${problem}</div>${controls}</div>`;
+}
+// yt-dlp errors are paragraphs; the queue only has room for the part that matters.
+function _mediaShortError(err) {
+ const m = String(err || '').toLowerCase();
+ if (m.includes('bot')) return 'YouTube blocked this on the server (sign-in check).';
+ if (m.includes('private')) return 'This video is private.';
+ if (m.includes('members-only')) return 'This video is members-only.';
+ if (m.includes('age')) return 'This video is age-restricted.';
+ if (m.includes('unavailable') || m.includes('not available')) return 'This video is unavailable.';
+ if (m.includes('yt-dlp is not available')) return 'The server cannot play media right now.';
+ return 'This media could not be prepared.';
}
}
// ── Queue management (streamer + channel mods) ───────────────
// ── Queue management (streamer + channel mods) ───────────────
@@ -5138,7 +5161,7 @@ function _renderMediaQuote(q) {
@@ -5138,7 +5161,7 @@ function _renderMediaQuote(q) {
const el = document.getElementById('ch-media-quote');
const el = document.getElementById('ch-media-quote');
if (!el) return;
if (!el) return;
const dur = q.duration_seconds ? formatDuration(q.duration_seconds) : 'unknown length';
const dur = q.duration_seconds ? formatDuration(q.duration_seconds) : 'unknown length';
- const thumb = q.thumbnail_url ? `<img src="${esc(q.thumbnail_url)}" alt="">` : '<div class="ch-media-thumb-ph"><i class="fa-solid fa-music"></i></div>';
+ const thumb = _mediaThumbHTML(q.thumbnail_url);
let priceLine, action;
let priceLine, action;
if (!q.allowed) {
if (!q.allowed) {
@@ -220,7 +220,10 @@ app.use(helmet({
@@ -220,7 +220,10 @@ app.use(helmet({
// esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restream
// esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restream
styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com"],
styleSrc: ["'self'", "'unsafe-inline'", "cdnjs.cloudflare.com", "fonts.googleapis.com"],
fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],
fontSrc: ["'self'", "fonts.gstatic.com", "cdnjs.cloudflare.com"],
- imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com"],
+ // i.ytimg.com / i.vimeocdn.com carry media-request thumbnails. Without them
+ // every queued item renders as a broken image, which is what the media
+ // request tab was doing for every YouTube link.
+ imgSrc: ["'self'", "data:", "blob:", "image.tmdb.org", "https://openvibe.network", "https://openvibe.media", "cdn.frankerfacez.com", "cdn.betterttv.net", "cdn.7tv.app", "https://files.kick.com", "https://i.ytimg.com", "https://img.youtube.com", "https://i.vimeocdn.com"],
connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com"],
connectSrc: ["'self'", "wss:", "https://openvibe.network", "https://openvibe.media", "https://openvibe.games", "https://cdn.jsdelivr.net", "https://esm.sh", "https://static.cloudflareinsights.com"],
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// VODs/clips play from openvibe.media (the /api proxies 302 there), which may
// itself redirect to presigned B2/R2 object-store URLs — all must be allowed
// itself redirect to presigned B2/R2 object-store URLs — all must be allowed
@@ -76,6 +76,20 @@ class MediaQueue {
@@ -76,6 +76,20 @@ class MediaQueue {
return Math.max(1, Number(settings.request_cost) || DEFAULTS.request_cost);
return Math.max(1, Number(settings.request_cost) || DEFAULTS.request_cost);
}
}
+ /**
+ * Why this request cannot be priced, or null if it can.
+ *
+ * Per-minute pricing needs a duration, and yt-dlp returns none when YouTube blocks the
+ * server. Falling back to the flat rate there would charge a number the viewer was
+ * never shown, so an unpriceable request is refused outright instead.
+ */
+ unpriceableReason(settings, durationSeconds) {
+ if (this.currencyOf(settings) === 'free') return null;
+ if (settings.cost_mode !== 'per_minute') return null;
+ if (Number.isFinite(durationSeconds) && durationSeconds > 0) return null;
+ return 'This channel charges per minute, and the length of that link could not be read.';
+ }
+
/** Normalised currency for a channel; defaults to OpenCoins as before. */
/** Normalised currency for a channel; defaults to OpenCoins as before. */
currencyOf(settings) {
currencyOf(settings) {
const c = String((settings && settings.currency) || DEFAULTS.currency).toLowerCase();
const c = String((settings && settings.currency) || DEFAULTS.currency).toLowerCase();
@@ -171,6 +185,8 @@ class MediaQueue {
@@ -171,6 +185,8 @@ class MediaQueue {
// Price it, then charge in whichever currency this channel is configured for.
// Price it, then charge in whichever currency this channel is configured for.
const currency = this.currencyOf(settings);
const currency = this.currencyOf(settings);
+ const unpriceable = this.unpriceableReason(settings, normalized.duration_seconds);
+ if (unpriceable) throw new Error(unpriceable);
// A free channel stores cost 0, so the queue does not display a price nobody paid.
// A free channel stores cost 0, so the queue does not display a price nobody paid.
const cost = currency === 'free' ? 0 : this.calculateCost(settings, normalized.duration_seconds);
const cost = currency === 'free' ? 0 : this.calculateCost(settings, normalized.duration_seconds);
if (cost > 0) {
if (cost > 0) {
@@ -219,6 +235,58 @@ class MediaQueue {
@@ -219,6 +235,58 @@ class MediaQueue {
return request;
return request;
}
}
+ /**
+ * Is this extraction error permanent, or worth retrying later?
+ *
+ * A viewer who pays for a video that can never play should get their money back in
+ * seconds, not have it sit `pending` at the top of the queue until the streamer
+ * happens to open the player and try it. But a transient network blip must NOT burn
+ * the request, so only unambiguously permanent failures refund here.
+ */
+ _isTerminalExtractionError(message) {
+ const m = String(message || '').toLowerCase();
+ return [
+ "sign in to confirm you're not a bot",
+ 'sign in to confirm you’re not a bot',
+ 'video unavailable',
+ 'private video',
+ 'members-only',
+ 'age-restricted',
+ 'confirm your age',
+ 'removed by the uploader',
+ 'account associated with this video has been terminated',
+ 'this video is not available',
+ 'yt-dlp is not available',
+ 'unsupported url',
+ ].some((needle) => m.includes(needle));
+ }
+
+ /**
+ * Mark a request dead and refund it when the failure is permanent. Returns true if
+ * the request was failed.
+ */
+ _failIfTerminal(requestId, message) {
+ if (!this._isTerminalExtractionError(message)) return false;
+ const request = db.getMediaRequestById(requestId);
+ // Only refund something still queued — a played item keeps its charge.
+ if (!request || (request.status !== 'pending' && request.status !== 'playing')) return false;
+ console.warn(`[MediaQueue] Request ${requestId} cannot play, refunding: ${message}`);
+ this.failRequest(requestId, this._viewerFacingError(message));
+ return true;
+ }
+
+ /** Turn a yt-dlp wall of text into one line a viewer can act on. */
+ _viewerFacingError(message) {
+ const m = String(message || '').toLowerCase();
+ if (m.includes('bot')) return 'YouTube blocked this video on the server (sign-in check). Refunded.';
+ if (m.includes('private')) return 'That video is private. Refunded.';
+ if (m.includes('members-only')) return 'That video is members-only. Refunded.';
+ if (m.includes('age')) return 'That video is age-restricted. Refunded.';
+ if (m.includes('unavailable') || m.includes('not available')) return 'That video is unavailable. Refunded.';
+ if (m.includes('yt-dlp is not available')) return 'The server cannot play media right now. Refunded.';
+ return 'That media could not be played. Refunded.';
+ }
+
/**
/**
* Extract a direct stream URL for a pending/playing request (background).
* Extract a direct stream URL for a pending/playing request (background).
* Updates the DB row when done.
* Updates the DB row when done.
@@ -243,6 +311,7 @@ class MediaQueue {
@@ -243,6 +311,7 @@ class MediaQueue {
download_status: 'failed',
download_status: 'failed',
last_error: 'yt-dlp is not available on the server',
last_error: 'yt-dlp is not available on the server',
});
});
+ this._failIfTerminal(requestId, 'yt-dlp is not available on the server');
return db.getMediaRequestById(requestId);
return db.getMediaRequestById(requestId);
}
}
@@ -271,13 +340,16 @@ class MediaQueue {
@@ -271,13 +340,16 @@ class MediaQueue {
try {
try {
return await this.downloadFileForRequest(requestId);
return await this.downloadFileForRequest(requestId);
} catch (downloadErr) {
} catch (downloadErr) {
+ const combined = `Extraction failed: ${err.message}. Download fallback failed: ${downloadErr.message}`;
db.updateMediaRequest(requestId, {
db.updateMediaRequest(requestId, {
stream_url: null,
stream_url: null,
embed_url: null,
embed_url: null,
download_status: 'failed',
download_status: 'failed',
- last_error: `Extraction failed: ${err.message}. Download fallback failed: ${downloadErr.message}`,
+ last_error: combined,
});
});
- this.broadcastQueueUpdate(request.streamer_id);
+ // Refunds immediately when the video can never play, rather than leaving a
+ // paid-for item stuck at the top of the queue.
+ if (!this._failIfTerminal(requestId, combined)) this.broadcastQueueUpdate(request.streamer_id);
return db.getMediaRequestById(requestId);
return db.getMediaRequestById(requestId);
}
}
}
}
@@ -179,6 +179,7 @@ router.post('/quote', optionalAuth, async (req, res) => {
@@ -179,6 +179,7 @@ router.post('/quote', optionalAuth, async (req, res) => {
const cost = currency === 'free' ? 0 : mediaQueue.calculateCost(settings, duration);
const cost = currency === 'free' ? 0 : mediaQueue.calculateCost(settings, duration);
const tooLong = duration > 0 && duration > maxDuration;
const tooLong = duration > 0 && duration > maxDuration;
+ const unpriceable = mediaQueue.unpriceableReason(settings, duration);
// What the viewer can actually pay right now, so the UI can say "you have X".
// What the viewer can actually pay right now, so the UI can say "you have X".
let balance = null;
let balance = null;
@@ -199,10 +200,10 @@ router.post('/quote', optionalAuth, async (req, res) => {
@@ -199,10 +200,10 @@ router.post('/quote', optionalAuth, async (req, res) => {
cost,
cost,
balance,
balance,
affordable: balance == null ? null : balance >= cost,
affordable: balance == null ? null : balance >= cost,
- allowed: !tooLong,
+ allowed: !tooLong && !unpriceable,
reason: tooLong
reason: tooLong
? `That video is ${Math.floor(duration / 60)}m${duration % 60}s — this channel allows up to ${Math.floor(maxDuration / 60)}m.`
? `That video is ${Math.floor(duration / 60)}m${duration % 60}s — this channel allows up to ${Math.floor(maxDuration / 60)}m.`
- : null,
+ : (unpriceable || null),
});
});
} catch (err) {
} catch (err) {
res.status(400).json({ error: err.message || 'Could not price that link' });
res.status(400).json({ error: err.message || 'Could not price that link' });