openvibers/openvibe.live Commits

Media requests: per-channel currency, up-front quotes, mod queue control

OP OpenVibers committed 19 Aug 2026 · 3167a3e51b · 8 files, +462 −68

Three gaps in the media request system:

1. Pricing was OpenCoins-only. flat/per-minute already worked, but the charge was
   hardwired to the network wallet, so a streamer could not run requests free or
   price them in Vibes or their own channel points. Adds a `currency` setting
   (free/vibes/opencoins/points) and a charge() that routes to the right ledger.
   The currency is recorded ON THE REQUEST, so a refund gives back what was
   actually taken rather than whatever the channel is set to today.

2. Viewers were charged blind. POST /media/quote resolves the link and returns
   the real title, length, exact price and the viewer's balance; the tab shows
   that card and only charges on confirm. A too-long video now reads as a clear
   "no" instead of a failed charge. Max length is also entered in minutes now.

3. Channel mods could not manage the queue — every management route assumed
   req.user.id WAS the streamer, so there was no way to even name another
   channel. Routes take an optional target and authorise via
   permissions.canModerateChannel; the channel tab renders the same controls to
   mods that the streamer gets.

Also: a Vibes/points charge no longer books a phantom OpenCoins ledger entry,
and free requests store cost 0 instead of a price nobody paid.

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 a0c4fbda..a90865a2 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -6896,6 +6896,41 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.ch-media-meta { min-width: 0; flex: 1; }
.ch-media-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ch-media-sub { font-size: 0.82rem; }
++.ch-media-cost { margin-left: 6px; white-space: nowrap; }
++.ch-media-cost i { color: var(--accent); }
++
++/* Queue controls — shown to the streamer and to their channel mods. */
++.ch-media-mod-bar {
++ display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
++ padding: 8px 10px; border-radius: 10px;
++ border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
++ background: color-mix(in srgb, var(--accent) 7%, transparent);
++ font-size: 0.85rem;
++}
++.ch-media-mod-bar > span:first-child { flex: 1; }
++.ch-media-actions { display: flex; gap: 5px; flex-shrink: 0; }
++.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); }
++
++/* Quote card: the exact price for this link, shown before anything is charged. */
++.ch-media-quote {
++ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
++ padding: 10px; border-radius: 10px;
++ border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
++ background: color-mix(in srgb, var(--accent) 8%, transparent);
++}
++.ch-media-quote.loading { justify-content: center; padding: 14px; }
++.ch-media-quote-meta { min-width: 0; flex: 1; }
++.ch-media-quote-price { margin-top: 4px; font-weight: 700; }
++.ch-media-quote-price i { color: var(--accent); }
++.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) {
++ .ch-media-quote-actions { width: 100%; }
++ .ch-media-quote-actions .btn { flex: 1; }
++ .ch-media-actions { width: 100%; justify-content: flex-end; }
++}
@media (max-width: 640px) { .ch-tab { padding: 9px 11px; font-size: 0.84rem; } }
/* ── Customizable offline screen ─────────────────────────────── */
ddiff --git a/public/js/app.js b/public/js/app.js
iindex ece7d1a1..741bd5fe 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -5014,6 +5014,9 @@ function goalReachedInWidget(goal) {
// ── Media Request tab ────────────────────────────────────────
let _mediaState = null;
++let _mediaCanManage = false;
++let _mediaPricing = null;
++let _mediaQuote = null; // the price the viewer has been shown and not yet confirmed
// Reveal the Media Request tab if the streamer has it enabled.
async function _initMediaRequestTab(username) {
const btn = document.getElementById('ch-tab-btn-media');
@@@ -5024,28 +5027,44 @@ async function _initMediaRequestTab(username) {
if (btn) btn.style.display = enabled ? '' : 'none';
} catch { if (btn) btn.style.display = 'none'; }
}
++// How a price reads to a viewer, e.g. "5 Vibes/min" or "free".
++function _mediaPriceLabel(p) {
++ if (!p || p.currency === 'free') return 'Free requests.';
++ if (p.cost_mode === 'per_minute') return `${p.cost_per_minute} ${p.currency_label} per minute of video.`;
++ return `${p.request_cost} ${p.currency_label} per request.`;
++}
++function _mediaCurrencyIcon(currency) {
++ if (currency === 'vibes') return 'fa-solid fa-bolt';
++ if (currency === 'points') return 'fa-solid fa-star';
++ if (currency === 'free') return 'fa-solid fa-gift';
++ return 'fa-solid fa-coins';
++}
async function loadChannelMedia(username = currentChannelUsername) {
const host = document.getElementById('ch-media-content');
if (!host || !username) return;
try {
const data = await api(`/media/channel/${encodeURIComponent(username)}`);
_mediaState = data.state || {};
++ _mediaCanManage = !!data.can_manage;
++ _mediaPricing = data.pricing || null;
const s = _mediaState.settings || {};
if (!s.enabled) { host.innerHTML = '<div class="ch-about-empty">Media requests are off for this channel.</div>'; return; }
-- const cost = Number(s.request_cost || 0);
const maxMin = Math.floor((Number(s.max_duration_seconds) || 0) / 60);
const np = _mediaState.now_playing;
const queue = _mediaState.queue || [];
const loggedIn = !!currentUser;
const sources = [s.allow_youtube && 'YouTube', s.allow_vimeo && 'Vimeo', s.allow_direct_media && 'direct media', s.allow_live && 'live'].filter(Boolean).join(', ');
++ const p = _mediaPricing;
host.innerHTML = `
<div class="ch-media">
<form class="ch-media-req" onsubmit="return submitMediaRequest(event)">
-- <input id="ch-media-input" type="text" placeholder="Paste a ${esc(sources || 'media')} URL to request…" ${loggedIn ? '' : 'disabled'}>
-- <button class="btn btn-primary" ${loggedIn ? '' : 'disabled'}><i class="fa-solid fa-plus"></i> Request</button>
++ <input id="ch-media-input" type="text" placeholder="Paste a ${esc(sources || 'media')} URL to request…" ${loggedIn ? '' : 'disabled'} oninput="_clearMediaQuote()">
++ <button class="btn btn-primary" ${loggedIn ? '' : 'disabled'}><i class="fa-solid fa-magnifying-glass"></i> Check price</button>
</form>
-- <div class="ch-media-hint muted">${loggedIn ? '' : '<i class="fa-solid fa-lock"></i> Log in to request. '}${cost > 0 ? `<i class="fa-solid fa-coins"></i> ${cost} coins per request. ` : 'Free requests. '}${maxMin ? `Max ${maxMin} min.` : ''}</div>
++ <div class="ch-media-hint muted">${loggedIn ? '' : '<i class="fa-solid fa-lock"></i> Log in to request. '}<i class="${_mediaCurrencyIcon(p && p.currency)}"></i> ${esc(_mediaPriceLabel(p))}${maxMin ? ` Max ${maxMin} min.` : ''}</div>
++ <div id="ch-media-quote"></div>
<div id="ch-media-status" class="ch-media-status"></div>
++ ${_mediaCanManage ? `<div class="ch-media-mod-bar"><span class="muted"><i class="fa-solid fa-shield-halved"></i> You can manage this queue</span><button class="btn btn-sm btn-outline" onclick="_mediaAdvance('played')"><i class="fa-solid fa-forward-step"></i> Next</button><button class="btn btn-sm btn-outline" onclick="_mediaAdvance('skipped')"><i class="fa-solid fa-ban"></i> Skip &amp; refund</button></div>` : ''}
${np ? `<div class="ch-media-now"><div class="ch-media-section-label"><i class="fa-solid fa-play"></i> Now Playing</div>${_mediaItemHTML(np)}</div>` : ''}
<div class="ch-media-section-label"><i class="fa-solid fa-list-ol"></i> Up Next (${queue.length})</div>
<div class="ch-media-queue">${queue.length ? queue.map((q, i) => _mediaItemHTML(q, i + 1)).join('') : '<div class="muted" style="padding:12px">Queue is empty — be the first to request something!</div>'}</div>
@@@ -5055,22 +5074,117 @@ async function loadChannelMedia(username = currentChannelUsername) {
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>';
-- 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')}</div></div></div>`;
++ 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.
++ const controls = _mediaCanManage && pos ? `
++ <div class="ch-media-actions">
++ <button class="btn btn-xs btn-outline" title="Play now" onclick="_mediaQueueAction(${m.id}, 'play')"><i class="fa-solid fa-play"></i></button>
++ <button class="btn btn-xs btn-outline" title="Move up" onclick="_mediaQueueAction(${m.id}, 'up')"><i class="fa-solid fa-arrow-up"></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 &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>`;
++}
++
++// ── Queue management (streamer + channel mods) ───────────────
++async function _mediaQueueAction(id, action) {
++ const body = { channelUsername: currentChannelUsername };
++ const status = document.getElementById('ch-media-status');
++ try {
++ if (action === 'play') await api(`/media/queue/${id}/play`, { method: 'POST', body });
++ else if (action === 'remove') await api(`/media/queue/${id}`, { method: 'DELETE', body });
++ else await api(`/media/queue/${id}/move`, { method: 'POST', body: { ...body, direction: action } });
++ loadChannelMedia(currentChannelUsername);
++ } catch (err) { if (status) { status.className = 'ch-media-status err'; status.textContent = err.message || 'Action failed'; } }
++}
++async function _mediaAdvance(status_) {
++ const status = document.getElementById('ch-media-status');
++ try {
++ await api('/media/advance', { method: 'POST', body: { channelUsername: currentChannelUsername, status: status_ } });
++ loadChannelMedia(currentChannelUsername);
++ } catch (err) { if (status) { status.className = 'ch-media-status err'; status.textContent = err.message || 'Failed to advance'; } }
++}
++
++// ── Quote → confirm → charge ─────────────────────────────────
++// The viewer is shown the real title, real length and exact price for THIS link before
++// anything is taken, then confirms. What they agreed to is what gets charged.
++function _clearMediaQuote() {
++ _mediaQuote = null;
++ const q = document.getElementById('ch-media-quote');
++ if (q) q.innerHTML = '';
}
async function submitMediaRequest(e) {
if (e) e.preventDefault();
const input = document.getElementById('ch-media-input');
const status = document.getElementById('ch-media-status');
++ const quoteEl = document.getElementById('ch-media-quote');
const val = input && input.value.trim();
if (!val) return false;
++ if (status) { status.className = 'ch-media-status'; status.textContent = ''; }
++ if (quoteEl) quoteEl.innerHTML = '<div class="ch-media-quote loading muted"><i class="fa-solid fa-spinner fa-spin"></i> Checking that link…</div>';
++ try {
++ const q = await api('/media/quote', { method: 'POST', body: { username: currentChannelUsername, input: val } });
++ _mediaQuote = { ...q, input: val };
++ _renderMediaQuote(_mediaQuote);
++ } catch (err) {
++ _mediaQuote = null;
++ if (quoteEl) quoteEl.innerHTML = '';
++ if (status) { status.className = 'ch-media-status err'; status.textContent = err.message || 'Could not read that link'; }
++ }
++ return false;
++}
++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>';
++
++ let priceLine, action;
++ if (!q.allowed) {
++ priceLine = `<div class="ch-media-quote-price err">${esc(q.reason || 'This request is not allowed.')}</div>`;
++ action = '';
++ } else if (q.cost <= 0) {
++ priceLine = '<div class="ch-media-quote-price ok"><i class="fa-solid fa-gift"></i> Free</div>';
++ action = `<button class="btn btn-primary" onclick="confirmMediaRequest()"><i class="fa-solid fa-plus"></i> Add to queue</button>`;
++ } else {
++ const per = q.cost_mode === 'per_minute'
++ ? ` <span class="muted">(${q.cost_per_minute}/min × ${Math.ceil((q.duration_seconds || 0) / 60)} min)</span>` : '';
++ const bal = q.balance == null ? ''
++ : ` <span class="muted">· you have ${q.balance}</span>`;
++ priceLine = `<div class="ch-media-quote-price"><i class="${_mediaCurrencyIcon(q.currency)}"></i> ${q.cost} ${esc(q.currency_label)}${per}${bal}</div>`;
++ action = q.affordable === false
++ ? `<div class="ch-media-status err">Not enough ${esc(q.currency_label)}.</div>`
++ : `<button class="btn btn-primary" onclick="confirmMediaRequest()"><i class="fa-solid fa-check"></i> Confirm &amp; pay ${q.cost}</button>`;
++ }
++
++ el.innerHTML = `
++ <div class="ch-media-quote">
++ <div class="ch-media-thumb">${thumb}</div>
++ <div class="ch-media-quote-meta">
++ <div class="ch-media-title">${esc(q.title || 'Media')}</div>
++ <div class="ch-media-sub muted">${esc(dur)}${q.provider ? ' · ' + esc(q.provider) : ''}</div>
++ ${priceLine}
++ </div>
++ <div class="ch-media-quote-actions">
++ ${action}
++ <button class="btn btn-sm btn-outline" onclick="_clearMediaQuote()">Cancel</button>
++ </div>
++ </div>`;
++}
++async function confirmMediaRequest() {
++ const q = _mediaQuote;
++ const status = document.getElementById('ch-media-status');
++ if (!q) return;
if (status) { status.className = 'ch-media-status'; status.textContent = 'Adding…'; }
try {
-- await api('/media/request', { method: 'POST', body: { username: currentChannelUsername, streamId: currentStreamId || undefined, input: val } });
++ await api('/media/request', { method: 'POST', body: { username: currentChannelUsername, streamId: currentStreamId || undefined, input: q.input } });
++ const input = document.getElementById('ch-media-input');
if (input) input.value = '';
++ _clearMediaQuote();
if (status) { status.className = 'ch-media-status ok'; status.textContent = '✓ Added to the queue!'; setTimeout(() => { status.textContent = ''; }, 2500); }
loadChannelMedia(currentChannelUsername);
} catch (err) { if (status) { status.className = 'ch-media-status err'; status.textContent = err.message || 'Request failed'; } }
-- return false;
}
// ── Edit mode ────────────────────────────────────────────────
ddiff --git a/public/js/media-player.js b/public/js/media-player.js
iindex f949710d..d38df3fb 100644
---- a/public/js/media-player.js
++++ b/public/js/media-player.js
@@@ -708,13 +708,43 @@ function renderSettings() {
form.request_cost.value = s.request_cost ?? 25;
form.cost_per_minute.value = s.cost_per_minute ?? 5;
form.cost_mode.value = s.cost_mode || 'flat';
++ form.currency.value = s.currency || 'opencoins';
form.max_per_user.value = s.max_per_user ?? 3;
-- form.max_duration_seconds.value = s.max_duration_seconds ?? 600;
++ // Stored in seconds, entered in minutes — nobody thinks about a max length in seconds.
++ form.max_duration_minutes.value = Math.max(1, Math.round((s.max_duration_seconds ?? 600) / 60));
form.allow_youtube.checked = !!s.allow_youtube;
form.allow_vimeo.checked = !!s.allow_vimeo;
form.allow_direct_media.checked = !!s.allow_direct_media;
form.allow_live.checked = !!s.allow_live;
form.auto_advance.checked = !!s.auto_advance;
++ onCurrencyChange(form.currency.value);
++}
++
++/**
++ * Grey out the price fields when requests are free, and spell out in words what the
++ * current combination actually costs a viewer — the two dropdowns plus two numbers are
++ * easy to misread on their own.
++ */
++function refreshPricingNote() {
++ const form = document.getElementById('mp-settings-form');
++ if (form) onCurrencyChange(form.currency.value);
++}
++
++function onCurrencyChange(currency) {
++ const form = document.getElementById('mp-settings-form');
++ if (!form) return;
++ const free = currency === 'free';
++ form.cost_mode.disabled = free;
++ form.request_cost.disabled = free;
++ form.cost_per_minute.disabled = free;
++
++ const note = document.getElementById('mp-pricing-note');
++ if (!note) return;
++ if (free) { note.textContent = 'Viewers request for free.'; return; }
++ const label = { vibes: 'Vibes', points: 'channel points', opencoins: 'OpenCoins' }[currency] || 'OpenCoins';
++ note.textContent = form.cost_mode.value === 'per_minute'
++ ? `A 4-minute video costs ${(Number(form.cost_per_minute.value) || 0) * 4} ${label} (rounded up to whole minutes).`
++ : `Every request costs ${Number(form.request_cost.value) || 0} ${label}, whatever its length.`;
}
async function saveSettings(event) {
@@@ -725,8 +755,9 @@ async function saveSettings(event) {
request_cost: Number(form.request_cost.value || 25),
cost_per_minute: Number(form.cost_per_minute.value || 5),
cost_mode: form.cost_mode.value,
++ currency: form.currency.value,
max_per_user: Number(form.max_per_user.value || 3),
-- max_duration_seconds: Number(form.max_duration_seconds.value || 600),
++ max_duration_seconds: Math.max(30, Number(form.max_duration_minutes.value || 10) * 60),
allow_youtube: form.allow_youtube.checked,
allow_vimeo: form.allow_vimeo.checked,
allow_direct_media: form.allow_direct_media.checked,
ddiff --git a/public/media-player.html b/public/media-player.html
iindex 1e28a6d5..a628dabf 100644
---- a/public/media-player.html
++++ b/public/media-player.html
@@@ -109,17 +109,26 @@
<label><span>Enabled</span><input type="checkbox" name="enabled"></label>
<fieldset class="mp-fieldset">
<legend>Pricing</legend>
++ <label><span>Charge in</span>
++ <select name="currency" class="mp-select" onchange="onCurrencyChange(this.value)">
++ <option value="free">Free — no charge</option>
++ <option value="opencoins">OpenCoins</option>
++ <option value="vibes">Vibes (paid to you)</option>
++ <option value="points">Your channel points</option>
++ </select>
++ </label>
<label><span>Mode</span>
-- <select name="cost_mode" class="mp-select">
++ <select name="cost_mode" class="mp-select" onchange="refreshPricingNote()">
<option value="flat">Flat rate</option>
<option value="per_minute">Per minute</option>
</select>
</label>
-- <label><span>Flat cost</span><input type="number" name="request_cost" min="1" max="100000"></label>
-- <label><span>Cost per minute</span><input type="number" name="cost_per_minute" min="1" max="10000"></label>
++ <label><span>Flat cost</span><input type="number" name="request_cost" min="1" max="100000" oninput="refreshPricingNote()"></label>
++ <label><span>Cost per minute</span><input type="number" name="cost_per_minute" min="1" max="10000" oninput="refreshPricingNote()"></label>
++ <p class="mp-muted mp-pricing-note" id="mp-pricing-note"></p>
</fieldset>
<label><span>Max per viewer</span><input type="number" name="max_per_user" min="1" max="25"></label>
-- <label><span>Max duration (sec)</span><input type="number" name="max_duration_seconds" min="30" max="7200"></label>
++ <label><span>Max length (minutes)</span><input type="number" name="max_duration_minutes" min="1" max="120" step="1"></label>
<label><span>YouTube</span><input type="checkbox" name="allow_youtube"></label>
<label><span>Vimeo</span><input type="checkbox" name="allow_vimeo"></label>
<label><span>Direct media</span><input type="checkbox" name="allow_direct_media"></label>
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 073a5ac8..2e9cfe16 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -835,12 +835,18 @@ function initDb() {
if (!mrCols.includes('file_path')) database.exec('ALTER TABLE media_requests ADD COLUMN file_path TEXT');
if (!mrCols.includes('playback_position')) database.exec('ALTER TABLE media_requests ADD COLUMN playback_position REAL DEFAULT 0');
if (!mrCols.includes('refunded')) database.exec('ALTER TABLE media_requests ADD COLUMN refunded INTEGER DEFAULT 0');
++ // The currency this request was actually charged in, captured at request time. A
++ // refund has to give back what was taken, so it cannot read the channel's current
++ // setting — a streamer switching from Vibes to points would otherwise refund the
++ // wrong currency to everyone still queued.
++ if (!mrCols.includes('currency')) database.exec("ALTER TABLE media_requests ADD COLUMN currency TEXT DEFAULT 'opencoins'");
const msCols = database.pragma('table_info(media_request_settings)').map(c => c.name);
if (!msCols.includes('cost_mode')) database.exec("ALTER TABLE media_request_settings ADD COLUMN cost_mode TEXT DEFAULT 'flat' CHECK(cost_mode IN ('flat','per_minute'))");
if (!msCols.includes('cost_per_minute')) database.exec('ALTER TABLE media_request_settings ADD COLUMN cost_per_minute INTEGER DEFAULT 5');
if (!msCols.includes('allow_live')) database.exec('ALTER TABLE media_request_settings ADD COLUMN allow_live INTEGER DEFAULT 0');
if (!msCols.includes('download_mode')) database.exec("ALTER TABLE media_request_settings ADD COLUMN download_mode TEXT DEFAULT 'stream' CHECK(download_mode IN ('stream','download'))");
++ if (!msCols.includes('currency')) database.exec("ALTER TABLE media_request_settings ADD COLUMN currency TEXT DEFAULT 'opencoins' CHECK(currency IN ('free','vibes','opencoins','points'))");
} catch (e) { console.warn('[DB] media columns migration:', e.message); }
// Migrate: create anon IP mapping table for persistent anon numbering
@@@ -5919,8 +5925,8 @@ function upsertMediaRequestSettings(userId, fields = {}) {
run(`INSERT INTO media_request_settings (
user_id, enabled, request_cost, max_per_user, max_duration_seconds,
allow_youtube, allow_vimeo, allow_direct_media, auto_advance,
-- cost_mode, cost_per_minute, allow_live, download_mode
-- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
++ cost_mode, cost_per_minute, allow_live, download_mode, currency
++ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
userId,
fields.enabled ?? 1,
fields.request_cost ?? 25,
@@@ -5934,6 +5940,7 @@ function upsertMediaRequestSettings(userId, fields = {}) {
fields.cost_per_minute ?? 5,
fields.allow_live ?? 0,
fields.download_mode ?? 'stream',
++ fields.currency ?? 'opencoins',
]);
} else if (Object.keys(fields).length) {
const sets = [];
@@@ -5949,12 +5956,12 @@ function upsertMediaRequestSettings(userId, fields = {}) {
return getMediaRequestSettingsByUserId(userId);
}
--function createMediaRequest({ streamer_id, stream_id, user_id, username, input, canonical_url, embed_url, provider, title, thumbnail_url, duration_seconds, cost, queue_position }) {
++function createMediaRequest({ streamer_id, stream_id, user_id, username, input, canonical_url, embed_url, provider, title, thumbnail_url, duration_seconds, cost, queue_position, currency }) {
return run(
`INSERT INTO media_requests (
streamer_id, stream_id, user_id, username, input, canonical_url, embed_url,
-- provider, title, thumbnail_url, duration_seconds, cost, queue_position
-- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ provider, title, thumbnail_url, duration_seconds, cost, queue_position, currency
++ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
streamer_id,
stream_id || null,
@@@ -5969,6 +5976,7 @@ function createMediaRequest({ streamer_id, stream_id, user_id, username, input,
duration_seconds ?? null,
cost,
queue_position ?? 0,
++ currency || 'opencoins',
]
);
}
ddiff --git a/server/db/schema.sql b/server/db/schema.sql
iindex 24838fe1..c0560269 100644
---- a/server/db/schema.sql
++++ b/server/db/schema.sql
@@@ -729,6 +729,7 @@ CREATE TABLE IF NOT EXISTS media_request_settings (
allow_vimeo INTEGER DEFAULT 1,
allow_direct_media INTEGER DEFAULT 1,
auto_advance INTEGER DEFAULT 1,
++ currency TEXT DEFAULT 'opencoins' CHECK(currency IN ('free','vibes','opencoins','points')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@@ -749,6 +750,7 @@ CREATE TABLE IF NOT EXISTS media_requests (
thumbnail_url TEXT,
duration_seconds INTEGER,
cost INTEGER NOT NULL DEFAULT 25,
++ currency TEXT DEFAULT 'opencoins',
queue_position INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'playing', 'played', 'skipped', 'removed', 'failed')),
requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
ddiff --git a/server/media/media-queue.js b/server/media/media-queue.js
iindex a3d9f79e..e75b2d49 100644
---- a/server/media/media-queue.js
++++ b/server/media/media-queue.js
@@@ -14,6 +14,14 @@ const DEFAULTS = {
auto_advance: 1,
cost_mode: 'flat',
cost_per_minute: 5,
++ // What a request costs, and in what. Flat vs per-minute already existed, but the
++ // charge was hardwired to OpenCoins, so a streamer had no way to run requests free
++ // or price them in anything else.
++ // free — no charge
++ // opencoins — network-wide wallet (default; previous behaviour)
++ // vibes — paid currency, credited to the streamer as a donation
++ // points — this channel's own points
++ currency: 'opencoins',
allow_live: 0,
download_mode: 'stream', // 'stream' = extract URL, 'download' = download file to disk
};
@@@ -68,6 +76,61 @@ class MediaQueue {
return Math.max(1, Number(settings.request_cost) || DEFAULTS.request_cost);
}
++ /** Normalised currency for a channel; defaults to OpenCoins as before. */
++ currencyOf(settings) {
++ const c = String((settings && settings.currency) || DEFAULTS.currency).toLowerCase();
++ return ['free', 'vibes', 'opencoins', 'points'].includes(c) ? c : 'opencoins';
++ }
++
++ /** Human label for a currency, used in quotes and error messages. */
++ currencyLabel(currency) {
++ switch (currency) {
++ case 'free': return 'free';
++ case 'vibes': return 'Vibes';
++ case 'points': return 'channel points';
++ default: return 'OpenCoins';
++ }
++ }
++
++ /**
++ * Take `cost` from the viewer in the channel's currency. Throws a message intended to
++ * be shown to the viewer verbatim. The viewer was quoted this exact figure by /quote
++ * before submitting, so what they agreed to is what gets taken.
++ */
++ async charge({ currency, cost, userId, streamerId, streamId, label }) {
++ if (currency === 'points') {
++ if (!db.deductChannelPoints(userId, streamerId, cost)) {
++ const have = db.getChannelPoints(userId, streamerId);
++ throw new Error(`Not enough channel points — this costs ${cost}, you have ${have}.`);
++ }
++ return;
++ }
++ if (currency === 'vibes') {
++ // Vibes have no generic spend: paying a streamer for a request IS a donation
++ // to them, so it goes through the same path and shows up in their totals.
++ try {
++ require('../monetization/vibes').donate(userId, streamerId, streamId || null, cost, label);
++ } catch (e) {
++ throw new Error(/insufficient/i.test(e?.message || '')
++ ? `Not enough Vibes — this costs ${cost}.`
++ : (e?.message || `Could not charge ${cost} Vibes.`));
++ }
++ return;
++ }
++ // opencoins — network wallet
++ try {
++ const wallet = require('../monetization/wallet-client');
++ const debited = await wallet.debit(
++ userId, cost, label,
++ `live:media_req:${streamerId}:${userId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`
++ );
++ if (!debited) throw new Error('OpenCoins wallet unavailable — link your OpenVibe.Network account first.');
++ } catch (e) {
++ if (e && e.status === 409) throw new Error(`Not enough OpenCoins — this costs ${cost}.`);
++ throw e;
++ }
++ }
++
/**
* Add a media request to the queue.
* Uses yt-dlp for metadata (title, duration, thumbnail) when available.
@@@ -106,24 +169,15 @@ class MediaQueue {
const duplicate = db.findActiveMediaRequestByCanonicalUrl(streamerId, normalized.canonical_url);
if (duplicate) throw new Error('That media is already in the queue');
-- // Calculate cost (flat or per-minute). Spends OpenCoins from the network
-- // wallet (openvibe.network) — the legacy local balance column is frozen.
-- const cost = this.calculateCost(settings, normalized.duration_seconds);
++ // Price it, then charge in whichever currency this channel is configured for.
++ const currency = this.currencyOf(settings);
++ // 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) {
-- const costDesc = settings.cost_mode === 'per_minute'
-- ? `${settings.cost_per_minute} coins/min`
-- : `${cost} coins`;
-- try {
-- const wallet = require('../monetization/wallet-client');
-- const debited = await wallet.debit(
-- userId, cost, `Media request: ${normalized.title || trimmed}`,
-- `live:media_req:${streamerId}:${userId}:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`
-- );
-- if (!debited) throw new Error('OpenCoins wallet unavailable — link your OpenVibe.Network account first.');
-- } catch (e) {
-- if (e && e.status === 409) throw new Error(`Not enough OpenCoins. This channel charges ${costDesc} per request.`);
-- throw e;
-- }
++ await this.charge({
++ currency, cost, userId, streamerId, streamId,
++ label: `Media request: ${normalized.title || trimmed}`,
++ });
}
const queuePosition = db.getMediaRequestMaxQueuePosition(streamerId) + 1;
@@@ -140,16 +194,21 @@ class MediaQueue {
thumbnail_url: normalized.thumbnail_url,
duration_seconds: normalized.duration_seconds,
cost,
++ currency,
queue_position: queuePosition,
});
-- db.createCoinTransaction({
-- user_id: userId,
-- stream_id: streamId,
-- amount: -cost,
-- type: 'redeem',
-- message: `Media request: ${normalized.title}`,
-- });
++ // The coin ledger tracks OpenCoins only — booking a Vibes or channel-points charge
++ // here would show up as a phantom coin spend in the viewer's history.
++ if (cost > 0 && currency === 'opencoins') {
++ db.createCoinTransaction({
++ user_id: userId,
++ stream_id: streamId,
++ amount: -cost,
++ type: 'redeem',
++ message: `Media request: ${normalized.title}`,
++ });
++ }
const request = db.getMediaRequestById(result.lastInsertRowid);
this.broadcastQueueUpdate(streamerId);
@@@ -334,12 +393,29 @@ class MediaQueue {
if (request.refunded) return 0;
const amount = request.cost || 0;
-- if (amount <= 0) return 0;
++ const currency = this.currencyOf({ currency: request.currency });
++ if (amount <= 0 || request.currency === 'free') return 0;
-- // Refund to the network OpenCoins wallet (idempotent per request id).
-- require('../monetization/wallet-client')
-- .credit(request.user_id, amount, `Refund: ${request.title || 'media request'}`, `live:media_refund:${request.id}`)
-- .catch((e) => console.warn('[MediaQueue] wallet refund failed:', e.message));
++ // Give back what was actually taken. `currency` is the one recorded on the request
++ // at charge time, not the channel's current setting.
++ const label = `Refund: ${request.title || 'media request'}`;
++ try {
++ if (currency === 'points') {
++ db.addChannelPoints(request.user_id, request.streamer_id, amount);
++ } else if (currency === 'vibes') {
++ // The charge was booked as a donation to the streamer, so unwind both sides.
++ db.deductVibesCashout(request.streamer_id, amount);
++ db.addVibes(request.user_id, amount);
++ } else {
++ // Idempotent per request id, so a double-refund is a no-op server-side.
++ require('../monetization/wallet-client')
++ .credit(request.user_id, amount, label, `live:media_refund:${request.id}`)
++ .catch((e) => console.warn('[MediaQueue] wallet refund failed:', e.message));
++ }
++ } catch (e) {
++ console.warn('[MediaQueue] refund failed:', e.message);
++ return 0;
++ }
db.updateMediaRequest(requestId, { refunded: 1 });
return amount;
ddiff --git a/server/media/routes.js b/server/media/routes.js
iindex 468e4f1d..329b3a77 100644
---- a/server/media/routes.js
++++ b/server/media/routes.js
@@@ -6,6 +6,44 @@ const downloader = require('./media-downloader');
const router = express.Router();
++const permissions = require('../auth/permissions');
++
++/**
++ * Resolve which channel's queue a management call is acting on, and authorise it.
++ *
++ * Every management route used to assume `req.user.id` WAS the streamer, which is why a
++ * channel mod could not touch the queue at all — there was no way to even name another
++ * channel. Callers may now pass `streamer` (id) or `channelUsername`; omitting both keeps
++ * the old behaviour of managing your own queue.
++ *
++ * Returns the streamer's user id, or throws an Error whose message is viewer-safe.
++ */
++function resolveQueueTarget(req) {
++ const explicit = cleanInt(req.body?.streamer ?? req.query?.streamer, null);
++ const uname = req.body?.channelUsername || req.query?.channelUsername;
++
++ let streamerId = explicit;
++ if (!streamerId && uname) streamerId = db.getUserByUsername(String(uname))?.id || null;
++ if (!streamerId) return req.user.id; // own queue
++ if (streamerId === req.user.id) return streamerId;
++
++ const channel = db.getChannelByUserId(streamerId);
++ if (!channel || !permissions.canModerateChannel(req.user, channel.id)) {
++ const err = new Error('You do not have permission to manage this queue');
++ err.status = 403;
++ throw err;
++ }
++ return streamerId;
++}
++
++/** Owner-or-mod check for a channel identified by its streamer's user id. */
++function canManageChannel(user, streamerId) {
++ if (!user || !streamerId) return false;
++ if (user.id === streamerId) return true;
++ const channel = db.getChannelByUserId(streamerId);
++ return !!channel && permissions.canModerateChannel(user, channel.id);
++}
++
function cleanInt(value, fallback) {
const num = parseInt(value, 10);
return Number.isFinite(num) ? num : fallback;
@@@ -31,6 +69,21 @@ router.get('/channel/:username', optionalAuth, (req, res) => {
live_stream: streams[0] || null,
state,
is_owner: !!req.user && req.user.id === user.id,
++ // Drives whether the channel's media tab renders queue controls. Mods get the
++ // same controls as the streamer; the server re-checks on every action.
++ can_manage: canManageChannel(req.user, user.id),
++ pricing: (() => {
++ const st = mediaQueue.getSettings(user.id);
++ const currency = mediaQueue.currencyOf(st);
++ return {
++ currency,
++ currency_label: mediaQueue.currencyLabel(currency),
++ cost_mode: st.cost_mode || 'flat',
++ request_cost: st.request_cost,
++ cost_per_minute: st.cost_per_minute,
++ max_duration_seconds: st.max_duration_seconds,
++ };
++ })(),
media_player_url: `/media/${user.username}`,
});
} catch (err) {
@@@ -58,6 +111,7 @@ router.put('/settings', requireAuth, (req, res) => {
cost_mode: 'cost_mode',
cost_per_minute: 'cost_per_minute',
download_mode: 'download_mode',
++ currency: 'currency',
};
for (const [key, target] of Object.entries(mapping)) {
@@@ -70,6 +124,11 @@ router.put('/settings', requireAuth, (req, res) => {
return res.status(400).json({ error: 'cost_mode must be flat or per_minute' });
}
fields[target] = req.body[key];
++ } else if (key === 'currency') {
++ if (!['free', 'vibes', 'opencoins', 'points'].includes(req.body[key])) {
++ return res.status(400).json({ error: 'currency must be free, vibes, opencoins or points' });
++ }
++ fields[target] = req.body[key];
} else if (key === 'download_mode') {
if (!['stream', 'download'].includes(req.body[key])) {
return res.status(400).json({ error: 'download_mode must be stream or download' });
@@@ -91,6 +150,65 @@ router.put('/settings', requireAuth, (req, res) => {
}
});
++/**
++ * Price a request BEFORE it is submitted.
++ *
++ * Chosen over reserve-and-settle because it is the version a viewer can actually reason
++ * about: they see the real title, the real length and the exact price for THIS link, then
++ * confirm. What they agreed to is what gets charged — nothing is taken and later adjusted.
++ * It also surfaces a too-long video as a clear "no" instead of a failed charge.
++ */
++router.post('/quote', optionalAuth, async (req, res) => {
++ try {
++ let streamerId = cleanInt(req.body.streamerId, null);
++ if (!streamerId && req.body.username) {
++ streamerId = db.getUserByUsername(String(req.body.username))?.id || null;
++ }
++ if (!streamerId) return res.status(400).json({ error: 'streamerId or username required' });
++
++ const input = String(req.body.input || '').trim();
++ if (!input) return res.status(400).json({ error: 'input required' });
++
++ const settings = mediaQueue.getSettings(streamerId);
++ if (!settings.enabled) return res.status(403).json({ error: 'Media requests are closed for this channel' });
++
++ const normalized = await mediaQueue.normalizeInput(input, settings);
++ const duration = Number(normalized.duration_seconds) || 0;
++ const maxDuration = Number(settings.max_duration_seconds) || 600;
++ const currency = mediaQueue.currencyOf(settings);
++ const cost = currency === 'free' ? 0 : mediaQueue.calculateCost(settings, duration);
++
++ const tooLong = duration > 0 && duration > maxDuration;
++
++ // What the viewer can actually pay right now, so the UI can say "you have X".
++ let balance = null;
++ if (req.user && currency === 'points') balance = db.getChannelPoints(req.user.id, streamerId);
++ else if (req.user && currency === 'vibes') balance = db.getUserById(req.user.id)?.openvibe_bucks_balance ?? null;
++
++ res.json({
++ title: normalized.title,
++ provider: normalized.provider,
++ thumbnail_url: normalized.thumbnail_url,
++ duration_seconds: duration,
++ max_duration_seconds: maxDuration,
++ too_long: tooLong,
++ currency,
++ currency_label: mediaQueue.currencyLabel(currency),
++ cost_mode: settings.cost_mode || 'flat',
++ cost_per_minute: settings.cost_per_minute,
++ cost,
++ balance,
++ affordable: balance == null ? null : balance >= cost,
++ allowed: !tooLong,
++ reason: tooLong
++ ? `That video is ${Math.floor(duration / 60)}m${duration % 60}s — this channel allows up to ${Math.floor(maxDuration / 60)}m.`
++ : null,
++ });
++ } catch (err) {
++ res.status(400).json({ error: err.message || 'Could not price that link' });
++ }
++});
++
router.post('/request', requireAuth, async (req, res) => {
try {
let streamerId = cleanInt(req.body.streamerId, null);
@@@ -129,64 +247,66 @@ router.post('/request', requireAuth, async (req, res) => {
router.post('/start', requireAuth, (req, res) => {
try {
-- const request = mediaQueue.startNext(req.user.id);
++ const request = mediaQueue.startNext(resolveQueueTarget(req));
res.json({ request });
} catch (err) {
-- res.status(400).json({ error: err.message || 'Failed to start next request' });
++ res.status(err.status || 400).json({ error: err.message || 'Failed to start next request' });
}
});
router.post('/advance', requireAuth, (req, res) => {
try {
-- const ended = mediaQueue.finishCurrent(req.user.id, req.body.status === 'skipped' ? 'skipped' : 'played');
-- const next = mediaQueue.startNext(req.user.id);
++ const target = resolveQueueTarget(req);
++ const ended = mediaQueue.finishCurrent(target, req.body.status === 'skipped' ? 'skipped' : 'played');
++ const next = mediaQueue.startNext(target);
res.json({ ended, next });
} catch (err) {
-- res.status(400).json({ error: err.message || 'Failed to advance queue' });
++ res.status(err.status || 400).json({ error: err.message || 'Failed to advance queue' });
}
});
router.post('/queue/:id/play', requireAuth, (req, res) => {
try {
-- if (db.getActiveMediaRequestByStreamer(req.user.id)) {
++ const target = resolveQueueTarget(req);
++ if (db.getActiveMediaRequestByStreamer(target)) {
return res.status(400).json({ error: 'Finish or skip the current item first' });
}
-- const request = db.getMediaRequestByStreamerAndId(req.user.id, req.params.id);
++ const request = db.getMediaRequestByStreamerAndId(target, req.params.id);
if (!request || request.status !== 'pending') return res.status(404).json({ error: 'Pending request not found' });
db.updateMediaRequest(request.id, { queue_position: 0 });
-- db.renormalizePendingMediaRequestPositions(req.user.id);
-- const started = mediaQueue.startNext(req.user.id);
++ db.renormalizePendingMediaRequestPositions(target);
++ const started = mediaQueue.startNext(target);
res.json({ request: started });
} catch (err) {
-- res.status(400).json({ error: err.message || 'Failed to start request' });
++ res.status(err.status || 400).json({ error: err.message || 'Failed to start request' });
}
});
router.post('/queue/:id/skip', requireAuth, (req, res) => {
try {
-- const request = mediaQueue.skip(req.user.id, cleanInt(req.params.id, 0));
++ const request = mediaQueue.skip(resolveQueueTarget(req), cleanInt(req.params.id, 0));
res.json({ request });
} catch (err) {
-- res.status(400).json({ error: err.message || 'Failed to skip request' });
++ res.status(err.status || 400).json({ error: err.message || 'Failed to skip request' });
}
});
router.post('/queue/:id/move', requireAuth, (req, res) => {
try {
const direction = req.body.direction === 'down' ? 'down' : 'up';
-- const request = mediaQueue.move(req.user.id, cleanInt(req.params.id, 0), direction);
++ const request = mediaQueue.move(resolveQueueTarget(req), cleanInt(req.params.id, 0), direction);
res.json({ request });
} catch (err) {
-- res.status(400).json({ error: err.message || 'Failed to reorder request' });
++ res.status(err.status || 400).json({ error: err.message || 'Failed to reorder request' });
}
});
router.delete('/queue/:id', requireAuth, (req, res) => {
try {
-- const request = mediaQueue.skip(req.user.id, cleanInt(req.params.id, 0));
++ const request = mediaQueue.skip(resolveQueueTarget(req), cleanInt(req.params.id, 0));
res.json({ request });
} catch (err) {
-- res.status(400).json({ error: err.message || 'Failed to remove request' });
++ res.status(err.status || 400).json({ error: err.message || 'Failed to remove request' });
}
});
@@@ -252,9 +372,8 @@ router.post('/queue/:id/refund', requireAuth, (req, res) => {
try {
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
if (!request) return res.status(404).json({ error: 'Request not found' });
-- // Only channel owner can refund
-- if (request.streamer_id !== req.user.id) {
-- return res.status(403).json({ error: 'Only the channel owner can issue refunds' });
++ if (!canManageChannel(req.user, request.streamer_id)) {
++ return res.status(403).json({ error: 'Only the channel owner or its mods can issue refunds' });
}
const amount = mediaQueue.refund(request.id);
res.json({ refunded: amount, request: db.getMediaRequestById(request.id) });
@@@ -268,8 +387,8 @@ router.post('/queue/:id/fail', requireAuth, (req, res) => {
try {
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
if (!request) return res.status(404).json({ error: 'Request not found' });
-- if (request.streamer_id !== req.user.id) {
-- return res.status(403).json({ error: 'Only the channel owner can report failures' });
++ if (!canManageChannel(req.user, request.streamer_id)) {
++ return res.status(403).json({ error: 'Only the channel owner or its mods can report failures' });
}
const failed = mediaQueue.failRequest(request.id, req.body.error || 'Playback failed');
res.json({ request: failed });