openvibers/openvibe.live Commits
Media requests: per-channel currency, up-front quotes, mod queue control
Changes
@@@ -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 ─────────────────────────────── */@@@ -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 & 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 & 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 & 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 ────────────────────────────────────────────────@@@ -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,@@@ -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>@@@ -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',]);}@@@ -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,@@@ -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;@@@ -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 });
@@ -6896,6 +6896,41 @@ textarea.form-input { resize: vertical; min-height: 60px; }
@@ -6896,6 +6896,41 @@ textarea.form-input { resize: vertical; min-height: 60px; }
.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; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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 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; } }
@media (max-width: 640px) { .ch-tab { padding: 9px 11px; font-size: 0.84rem; } }
/* ── Customizable offline screen ─────────────────────────────── */
/* ── Customizable offline screen ─────────────────────────────── */
@@ -5014,6 +5014,9 @@ function goalReachedInWidget(goal) {
@@ -5014,6 +5014,9 @@ function goalReachedInWidget(goal) {
// ── Media Request tab ────────────────────────────────────────
// ── Media Request tab ────────────────────────────────────────
let _mediaState = null;
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.
// Reveal the Media Request tab if the streamer has it enabled.
async function _initMediaRequestTab(username) {
async function _initMediaRequestTab(username) {
const btn = document.getElementById('ch-tab-btn-media');
const btn = document.getElementById('ch-tab-btn-media');
@@ -5024,28 +5027,44 @@ async function _initMediaRequestTab(username) {
@@ -5024,28 +5027,44 @@ async function _initMediaRequestTab(username) {
if (btn) btn.style.display = enabled ? '' : 'none';
if (btn) btn.style.display = enabled ? '' : 'none';
} catch { if (btn) btn.style.display = '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) {
async function loadChannelMedia(username = currentChannelUsername) {
const host = document.getElementById('ch-media-content');
const host = document.getElementById('ch-media-content');
if (!host || !username) return;
if (!host || !username) return;
try {
try {
const data = await api(`/media/channel/${encodeURIComponent(username)}`);
const data = await api(`/media/channel/${encodeURIComponent(username)}`);
_mediaState = data.state || {};
_mediaState = data.state || {};
+ _mediaCanManage = !!data.can_manage;
+ _mediaPricing = data.pricing || null;
const s = _mediaState.settings || {};
const s = _mediaState.settings || {};
if (!s.enabled) { host.innerHTML = '<div class="ch-about-empty">Media requests are off for this channel.</div>'; return; }
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 maxMin = Math.floor((Number(s.max_duration_seconds) || 0) / 60);
const np = _mediaState.now_playing;
const np = _mediaState.now_playing;
const queue = _mediaState.queue || [];
const queue = _mediaState.queue || [];
const loggedIn = !!currentUser;
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 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 = `
host.innerHTML = `
<div class="ch-media">
<div class="ch-media">
<form class="ch-media-req" onsubmit="return submitMediaRequest(event)">
<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>
</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>
<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 & 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>` : ''}
${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-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>
<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) {
@@ -5055,22 +5074,117 @@ async function loadChannelMedia(username = currentChannelUsername) {
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 = 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 & 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) {
async function submitMediaRequest(e) {
if (e) e.preventDefault();
if (e) e.preventDefault();
const input = document.getElementById('ch-media-input');
const input = document.getElementById('ch-media-input');
const status = document.getElementById('ch-media-status');
const status = document.getElementById('ch-media-status');
+ const quoteEl = document.getElementById('ch-media-quote');
const val = input && input.value.trim();
const val = input && input.value.trim();
if (!val) return false;
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 & 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…'; }
if (status) { status.className = 'ch-media-status'; status.textContent = 'Adding…'; }
try {
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 = '';
if (input) input.value = '';
+ _clearMediaQuote();
if (status) { status.className = 'ch-media-status ok'; status.textContent = '✓ Added to the queue!'; setTimeout(() => { status.textContent = ''; }, 2500); }
if (status) { status.className = 'ch-media-status ok'; status.textContent = '✓ Added to the queue!'; setTimeout(() => { status.textContent = ''; }, 2500); }
loadChannelMedia(currentChannelUsername);
loadChannelMedia(currentChannelUsername);
} catch (err) { if (status) { status.className = 'ch-media-status err'; status.textContent = err.message || 'Request failed'; } }
} catch (err) { if (status) { status.className = 'ch-media-status err'; status.textContent = err.message || 'Request failed'; } }
- return false;
}
}
// ── Edit mode ────────────────────────────────────────────────
// ── Edit mode ────────────────────────────────────────────────
@@ -708,13 +708,43 @@ function renderSettings() {
@@ -708,13 +708,43 @@ function renderSettings() {
form.request_cost.value = s.request_cost ?? 25;
form.request_cost.value = s.request_cost ?? 25;
form.cost_per_minute.value = s.cost_per_minute ?? 5;
form.cost_per_minute.value = s.cost_per_minute ?? 5;
form.cost_mode.value = s.cost_mode || 'flat';
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_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_youtube.checked = !!s.allow_youtube;
form.allow_vimeo.checked = !!s.allow_vimeo;
form.allow_vimeo.checked = !!s.allow_vimeo;
form.allow_direct_media.checked = !!s.allow_direct_media;
form.allow_direct_media.checked = !!s.allow_direct_media;
form.allow_live.checked = !!s.allow_live;
form.allow_live.checked = !!s.allow_live;
form.auto_advance.checked = !!s.auto_advance;
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) {
async function saveSettings(event) {
@@ -725,8 +755,9 @@ async function saveSettings(event) {
@@ -725,8 +755,9 @@ async function saveSettings(event) {
request_cost: Number(form.request_cost.value || 25),
request_cost: Number(form.request_cost.value || 25),
cost_per_minute: Number(form.cost_per_minute.value || 5),
cost_per_minute: Number(form.cost_per_minute.value || 5),
cost_mode: form.cost_mode.value,
cost_mode: form.cost_mode.value,
+ currency: form.currency.value,
max_per_user: Number(form.max_per_user.value || 3),
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_youtube: form.allow_youtube.checked,
allow_vimeo: form.allow_vimeo.checked,
allow_vimeo: form.allow_vimeo.checked,
allow_direct_media: form.allow_direct_media.checked,
allow_direct_media: form.allow_direct_media.checked,
@@ -109,17 +109,26 @@
@@ -109,17 +109,26 @@
<label><span>Enabled</span><input type="checkbox" name="enabled"></label>
<label><span>Enabled</span><input type="checkbox" name="enabled"></label>
<fieldset class="mp-fieldset">
<fieldset class="mp-fieldset">
<legend>Pricing</legend>
<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>
<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="flat">Flat rate</option>
<option value="per_minute">Per minute</option>
<option value="per_minute">Per minute</option>
</select>
</select>
</label>
</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>
</fieldset>
<label><span>Max per viewer</span><input type="number" name="max_per_user" min="1" max="25"></label>
<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>YouTube</span><input type="checkbox" name="allow_youtube"></label>
<label><span>Vimeo</span><input type="checkbox" name="allow_vimeo"></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>
<label><span>Direct media</span><input type="checkbox" name="allow_direct_media"></label>
@@ -835,12 +835,18 @@ function initDb() {
@@ -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('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('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');
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);
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_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('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('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('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); }
} catch (e) { console.warn('[DB] media columns migration:', e.message); }
// Migrate: create anon IP mapping table for persistent anon numbering
// Migrate: create anon IP mapping table for persistent anon numbering
@@ -5919,8 +5925,8 @@ function upsertMediaRequestSettings(userId, fields = {}) {
@@ -5919,8 +5925,8 @@ function upsertMediaRequestSettings(userId, fields = {}) {
run(`INSERT INTO media_request_settings (
run(`INSERT INTO media_request_settings (
user_id, enabled, request_cost, max_per_user, max_duration_seconds,
user_id, enabled, request_cost, max_per_user, max_duration_seconds,
allow_youtube, allow_vimeo, allow_direct_media, auto_advance,
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,
userId,
fields.enabled ?? 1,
fields.enabled ?? 1,
fields.request_cost ?? 25,
fields.request_cost ?? 25,
@@ -5934,6 +5940,7 @@ function upsertMediaRequestSettings(userId, fields = {}) {
@@ -5934,6 +5940,7 @@ function upsertMediaRequestSettings(userId, fields = {}) {
fields.cost_per_minute ?? 5,
fields.cost_per_minute ?? 5,
fields.allow_live ?? 0,
fields.allow_live ?? 0,
fields.download_mode ?? 'stream',
fields.download_mode ?? 'stream',
+ fields.currency ?? 'opencoins',
]);
]);
} else if (Object.keys(fields).length) {
} else if (Object.keys(fields).length) {
const sets = [];
const sets = [];
@@ -5949,12 +5956,12 @@ function upsertMediaRequestSettings(userId, fields = {}) {
@@ -5949,12 +5956,12 @@ function upsertMediaRequestSettings(userId, fields = {}) {
return getMediaRequestSettingsByUserId(userId);
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(
return run(
`INSERT INTO media_requests (
`INSERT INTO media_requests (
streamer_id, stream_id, user_id, username, input, canonical_url, embed_url,
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,
streamer_id,
stream_id || null,
stream_id || null,
@@ -5969,6 +5976,7 @@ function createMediaRequest({ streamer_id, stream_id, user_id, username, input,
@@ -5969,6 +5976,7 @@ function createMediaRequest({ streamer_id, stream_id, user_id, username, input,
duration_seconds ?? null,
duration_seconds ?? null,
cost,
cost,
queue_position ?? 0,
queue_position ?? 0,
+ currency || 'opencoins',
]
]
);
);
}
}
@@ -729,6 +729,7 @@ CREATE TABLE IF NOT EXISTS media_request_settings (
@@ -729,6 +729,7 @@ CREATE TABLE IF NOT EXISTS media_request_settings (
allow_vimeo INTEGER DEFAULT 1,
allow_vimeo INTEGER DEFAULT 1,
allow_direct_media INTEGER DEFAULT 1,
allow_direct_media INTEGER DEFAULT 1,
auto_advance 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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -749,6 +750,7 @@ CREATE TABLE IF NOT EXISTS media_requests (
@@ -749,6 +750,7 @@ CREATE TABLE IF NOT EXISTS media_requests (
thumbnail_url TEXT,
thumbnail_url TEXT,
duration_seconds INTEGER,
duration_seconds INTEGER,
cost INTEGER NOT NULL DEFAULT 25,
cost INTEGER NOT NULL DEFAULT 25,
+ currency TEXT DEFAULT 'opencoins',
queue_position INTEGER DEFAULT 0,
queue_position INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'playing', 'played', 'skipped', 'removed', 'failed')),
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'playing', 'played', 'skipped', 'removed', 'failed')),
requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
requested_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -14,6 +14,14 @@ const DEFAULTS = {
@@ -14,6 +14,14 @@ const DEFAULTS = {
auto_advance: 1,
auto_advance: 1,
cost_mode: 'flat',
cost_mode: 'flat',
cost_per_minute: 5,
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,
allow_live: 0,
download_mode: 'stream', // 'stream' = extract URL, 'download' = download file to disk
download_mode: 'stream', // 'stream' = extract URL, 'download' = download file to disk
};
};
@@ -68,6 +76,61 @@ class MediaQueue {
@@ -68,6 +76,61 @@ class MediaQueue {
return Math.max(1, Number(settings.request_cost) || DEFAULTS.request_cost);
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.
* Add a media request to the queue.
* Uses yt-dlp for metadata (title, duration, thumbnail) when available.
* Uses yt-dlp for metadata (title, duration, thumbnail) when available.
@@ -106,24 +169,15 @@ class MediaQueue {
@@ -106,24 +169,15 @@ class MediaQueue {
const duplicate = db.findActiveMediaRequestByCanonicalUrl(streamerId, normalized.canonical_url);
const duplicate = db.findActiveMediaRequestByCanonicalUrl(streamerId, normalized.canonical_url);
if (duplicate) throw new Error('That media is already in the queue');
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) {
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;
const queuePosition = db.getMediaRequestMaxQueuePosition(streamerId) + 1;
@@ -140,16 +194,21 @@ class MediaQueue {
@@ -140,16 +194,21 @@ class MediaQueue {
thumbnail_url: normalized.thumbnail_url,
thumbnail_url: normalized.thumbnail_url,
duration_seconds: normalized.duration_seconds,
duration_seconds: normalized.duration_seconds,
cost,
cost,
+ currency,
queue_position: queuePosition,
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);
const request = db.getMediaRequestById(result.lastInsertRowid);
this.broadcastQueueUpdate(streamerId);
this.broadcastQueueUpdate(streamerId);
@@ -334,12 +393,29 @@ class MediaQueue {
@@ -334,12 +393,29 @@ class MediaQueue {
if (request.refunded) return 0;
if (request.refunded) return 0;
const amount = request.cost || 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 });
db.updateMediaRequest(requestId, { refunded: 1 });
return amount;
return amount;
@@ -6,6 +6,44 @@ const downloader = require('./media-downloader');
@@ -6,6 +6,44 @@ const downloader = require('./media-downloader');
const router = express.Router();
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) {
function cleanInt(value, fallback) {
const num = parseInt(value, 10);
const num = parseInt(value, 10);
return Number.isFinite(num) ? num : fallback;
return Number.isFinite(num) ? num : fallback;
@@ -31,6 +69,21 @@ router.get('/channel/:username', optionalAuth, (req, res) => {
@@ -31,6 +69,21 @@ router.get('/channel/:username', optionalAuth, (req, res) => {
live_stream: streams[0] || null,
live_stream: streams[0] || null,
state,
state,
is_owner: !!req.user && req.user.id === user.id,
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}`,
media_player_url: `/media/${user.username}`,
});
});
} catch (err) {
} catch (err) {
@@ -58,6 +111,7 @@ router.put('/settings', requireAuth, (req, res) => {
@@ -58,6 +111,7 @@ router.put('/settings', requireAuth, (req, res) => {
cost_mode: 'cost_mode',
cost_mode: 'cost_mode',
cost_per_minute: 'cost_per_minute',
cost_per_minute: 'cost_per_minute',
download_mode: 'download_mode',
download_mode: 'download_mode',
+ currency: 'currency',
};
};
for (const [key, target] of Object.entries(mapping)) {
for (const [key, target] of Object.entries(mapping)) {
@@ -70,6 +124,11 @@ router.put('/settings', requireAuth, (req, res) => {
@@ -70,6 +124,11 @@ router.put('/settings', requireAuth, (req, res) => {
return res.status(400).json({ error: 'cost_mode must be flat or per_minute' });
return res.status(400).json({ error: 'cost_mode must be flat or per_minute' });
}
}
fields[target] = req.body[key];
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') {
} else if (key === 'download_mode') {
if (!['stream', 'download'].includes(req.body[key])) {
if (!['stream', 'download'].includes(req.body[key])) {
return res.status(400).json({ error: 'download_mode must be stream or download' });
return res.status(400).json({ error: 'download_mode must be stream or download' });
@@ -91,6 +150,65 @@ router.put('/settings', requireAuth, (req, res) => {
@@ -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) => {
router.post('/request', requireAuth, async (req, res) => {
try {
try {
let streamerId = cleanInt(req.body.streamerId, null);
let streamerId = cleanInt(req.body.streamerId, null);
@@ -129,64 +247,66 @@ router.post('/request', requireAuth, async (req, res) => {
@@ -129,64 +247,66 @@ router.post('/request', requireAuth, async (req, res) => {
router.post('/start', requireAuth, (req, res) => {
router.post('/start', requireAuth, (req, res) => {
try {
try {
- const request = mediaQueue.startNext(req.user.id);
+ const request = mediaQueue.startNext(resolveQueueTarget(req));
res.json({ request });
res.json({ request });
} catch (err) {
} 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) => {
router.post('/advance', requireAuth, (req, res) => {
try {
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 });
res.json({ ended, next });
} catch (err) {
} 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) => {
router.post('/queue/:id/play', requireAuth, (req, res) => {
try {
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' });
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' });
if (!request || request.status !== 'pending') return res.status(404).json({ error: 'Pending request not found' });
db.updateMediaRequest(request.id, { queue_position: 0 });
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 });
res.json({ request: started });
} catch (err) {
} 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) => {
router.post('/queue/:id/skip', requireAuth, (req, res) => {
try {
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 });
res.json({ request });
} catch (err) {
} 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) => {
router.post('/queue/:id/move', requireAuth, (req, res) => {
try {
try {
const direction = req.body.direction === 'down' ? 'down' : 'up';
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 });
res.json({ request });
} catch (err) {
} 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) => {
router.delete('/queue/:id', requireAuth, (req, res) => {
try {
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 });
res.json({ request });
} catch (err) {
} 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) => {
@@ -252,9 +372,8 @@ router.post('/queue/:id/refund', requireAuth, (req, res) => {
try {
try {
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
if (!request) return res.status(404).json({ error: 'Request not found' });
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);
const amount = mediaQueue.refund(request.id);
res.json({ refunded: amount, request: db.getMediaRequestById(request.id) });
res.json({ refunded: amount, request: db.getMediaRequestById(request.id) });
@@ -268,8 +387,8 @@ router.post('/queue/:id/fail', requireAuth, (req, res) => {
@@ -268,8 +387,8 @@ router.post('/queue/:id/fail', requireAuth, (req, res) => {
try {
try {
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
const request = db.getMediaRequestById(cleanInt(req.params.id, 0));
if (!request) return res.status(404).json({ error: 'Request not found' });
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');
const failed = mediaQueue.failRequest(request.id, req.body.error || 'Playback failed');
res.json({ request: failed });
res.json({ request: failed });