openvibers/openvibe.live Commits

Media requests: unblock thumbnails, refund dead requests, fix item layout

OP OpenVibers committed 19 Aug 2026 · 266c9f7a05 · 5 files, +127 −16

Four separate faults behind "the media request is so broken":

1. CSP img-src had no i.ytimg.com, so the browser blocked EVERY YouTube
   thumbnail. The URLs were correct and fetched 200 from the server, which is
   why this looked like bad data. Adds ytimg/img.youtube/vimeocdn.

2. A request whose extraction fails terminally (YouTube's sign-in check,
   private, unavailable) was charged and then sat `pending` at the top of the
   queue forever — the only refund path was the streamer opening the player and
   trying to play it. Terminal failures now fail and refund immediately;
   transient errors still stay queued for retry rather than burning the request.

3. The queue item lost its title entirely: nowrap+ellipsis inside a flex row
   collapsed to zero width once the new mod controls were added, and the buttons
   themselves were clipped off the right edge. Title now clamps to two wrapped
   lines and the controls drop to their own row.

4. Per-minute pricing silently fell back to the flat rate when the duration was
   unknown, charging a number the viewer was never quoted. That is now refused
   with a clear reason instead.

Also: failed items show why they failed where they sit, and a thumbnail that
404s reveals a placeholder rather than a broken-image glyph.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Xw6Sf5zzSALw8m93XNu5JE

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex a90865a2..14ea2114 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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; } }
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 741bd5fe..2434bbe8 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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 &amp; 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) {
ddiff --git a/server/index.js b/server/index.js
iindex 3351fd94..2064bf0f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -220,7 +220,10 @@ app.use(helmet({
// esm.sh: mediasoup-client dynamic import for WebRTC SFU restreaming + RS restream
styleSrc: ["'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
ddiff --git a/server/media/media-queue.js b/server/media/media-queue.js
iindex e75b2d49..bd1f91bb 100644
---- a/server/media/media-queue.js
++++ b/server/media/media-queue.js
@@@ -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);
}
}
ddiff --git a/server/media/routes.js b/server/media/routes.js
iindex 329b3a77..f30911f4 100644
---- a/server/media/routes.js
++++ b/server/media/routes.js
@@@ -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' });