openvibers/openvibe.live Commits
A streamer can turn AI Moments off for their channel (roadmap 33.7/33.10): channels.ai_derivation_enabled (on by default, idempotent ADD COLUMN; db.isAiDerivationEnabled, true for an account with no channel row), set through PUT /api/streams/channel and a new dashboard Home card, 'AI Moments from my streams'. Off, the auto-clip job skips the channel's live streams before looking at the recording, leaves its VODs out of the backfill pool and clipVodMoment cuts nothing; the AI moments job never picks its VODs (no moment pastes, no clips from them); the stream-memory job posts no 'caught live' pastes (memory capture, the AI overview and captions are unchanged); and the after-show report is the stats-only one with no model call. Moments already made stay until deleted from Content > AI Moments. The AI-assigned category is marked 'inferred' on the channel page (live, offline and slot switch; built with DOM nodes) and on the VOD/clip detail chips (stream_category_inferred from the API). llms.txt says a channel can opt out. Also: the Offline Screen card read data.channel from GET /streams/channel, which answers the channel row itself, so it always showed the default. test/ai-derivation-optout.test.js
Changes
@@@ -1381,3 +1381,6 @@ body.controls-docked .messenger-toggle { z-index: 1100; }font-size: 0.68rem; font-weight: 700; letter-spacing: .02em; color: #fff;background: linear-gradient(135deg, #6d28d9, #0e7490);}++++/* An AI-inferred category says so ("Gaming · inferred", roadmap 33.4). */++.ch-category-badge .cat-inferred { font-weight: 500; opacity: .78; font-size: .9em; }@@@ -404,3 +404,4 @@.clp-ai-attribution a { color: var(--accent); font-weight: 600; text-decoration: none; }.clp-ai-attribution a:hover { text-decoration: underline; }.clp-ai-how { font-size: 0.8rem; margin-top: 2px; }++.detail-chip .cat-inferred { opacity: .78; }@@@ -375,7 +375,7 @@<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) --><div class="dash-card dash-card-wide" id="dash-card-aimoments"><h3 style="margin:0 0 6px"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments of My Stream</h3>-- <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them.</p>++ <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home → AI Moments from my streams.</p><div id="dash-ai-clips" class="vod-grid"></div></div>@@@ -495,6 +495,17 @@</div></div>++ <!-- AI Moments from my streams: the channel's AI derivation switch (roadmap 33.7) -->++ <div class="dash-card dash-card-wide" id="dash-card-ai-derivation">++ <h3><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments from my streams</h3>++ <label class="staff-inline-toggle" style="margin-bottom:8px">++ <input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)">++ <span>Let OpenVibe's AI make Moments from my streams</span>++ </label>++ <p class="muted" style="font-size:0.85rem;margin:0">Moments are auto-clips cut when chat reacts, frames the AI picks and posts as notes, and AI-written after-show recaps. They are always labelled as AI-made, never credited to you, and kept out of search. Turned off, the AI makes no new ones from your streams (your after-show report keeps its stats). Moments already made stay until you delete them under Content → AI Moments.</p>++ <span id="dash-ai-derivation-status" class="muted" style="font-size:0.82rem"></span>++ </div>++<!-- Offline Screen (migrated from /settings) --><div class="dash-card dash-card-wide" id="dash-card-offline"><h3><i class="fa-solid fa-image"></i> Offline Screen</h3>@@@ -28,6 +28,27 @@ function _updateMicOnlyOverlay(browserMode, streamingMethod) {container.appendChild(overlay);}let _activeChannelIsOwnerRank = false;++++/**++ * The category pill. A category the AI inferred from the stream (ai_category) says so, "Gaming ·++ * inferred" (roadmap 33.4); one the streamer chose is shown plain. Stream rows carry `category` as++ * the effective value (ai_category when there is one) and `ai_category` itself.++ */++function _isInferredCategory(row) { return !!(row && row.ai_category && row.ai_category === row.category); }++function _setCategoryBadge(el, value, inferred) {++ if (!el) return;++ el.textContent = _capTag(value);++ el.classList.toggle('is-inferred', !!inferred);++ if (inferred) {++ const mark = document.createElement('span');++ mark.className = 'cat-inferred';++ mark.textContent = ' · inferred';++ el.append(mark);++ el.title = "Category inferred by OpenVibe's AI from the stream";++ } else {++ el.removeAttribute('title');++ }++}const CHANNEL_VODS_PAGE_SIZE = 12;const CHANNEL_CLIPS_PAGE_SIZE = 12;const channelVodsPageByUser = Object.create(null);@@@ -667,7 +688,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI_activeChannelIsOwnerRank = !!ch.is_owner;const _chUser = document.getElementById('ch-username');if (_chUser) _chUser.style.display = 'none';-- document.getElementById('ch-category-badge').textContent = _capTag((liveStreams[0] && liveStreams[0].category) || ch.ai_category || ch.category || 'Live');++ { const _ls0 = liveStreams[0], _liveCat = _ls0 && _ls0.category;++ _setCategoryBadge(document.getElementById('ch-category-badge'), _liveCat || ch.ai_category || ch.category || 'Live',++ _liveCat ? _isInferredCategory(_ls0) : !!ch.ai_category); }document.getElementById('ch-follower-count').textContent = `${ch.follower_count || 0} followers`;setupFollowBtn(document.getElementById('ch-btn-follow'));setupBanBtn(document.getElementById('ch-btn-ban'));@@@ -728,7 +751,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIdocument.getElementById('ch-username-offline').textContent = '@' + ch.username;document.getElementById('ch-description-offline').textContent = ch.description || '';document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;-- document.getElementById('ch-category-badge-offline').textContent = _capTag(ch.ai_category || ch.category || 'Offline');++ _setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);setupFollowBtn(document.getElementById('ch-btn-follow-offline'));setupBanBtn(document.getElementById('ch-btn-ban-offline'));@@@ -1539,7 +1562,7 @@ function activateChannelStream(stream) {setPageTitle(`${stream.title || 'Live'} — ${stream.display_name || stream.username || ''}`.trim());// Category pill reflects THIS live slot's category (set in /broadcast), not the// channel's stale default.-- if (stream.category) { const _cb = document.getElementById('ch-category-badge'); if (_cb) _cb.textContent = _capTag(stream.category); }++ if (stream.category) _setCategoryBadge(document.getElementById('ch-category-badge'), stream.category, _isInferredCategory(stream));// Stream-type badge only (Screen Share / Audio Only / Camera). The WEBRTC/RTMP// protocol tag was removed from the header — that info now lives in the player's// stats overlay, which is more useful than the raw transport for viewers.@@@ -238,7 +238,7 @@ async function loadVodPlayer(vodId, seekTo) {const extraDetails = document.getElementById('vp-extra-details');if (extraDetails) {let chips = '';-- if (v.stream_category) chips += `<span class="detail-chip"><i class="fa-solid fa-tag"></i> ${esc(_capTag(v.stream_category))}</span>`;++ if (v.stream_category) chips += `<span class="detail-chip"${v.stream_category_inferred ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}><i class="fa-solid fa-tag"></i> ${esc(_capTag(v.stream_category))}${v.stream_category_inferred ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>`;if (v.stream_peak_viewers) chips += `<span class="detail-chip"><i class="fa-solid fa-users"></i> Peak: ${v.stream_peak_viewers}</span>`;if (v.stream_started_at) {const streamDate = new Date(v.stream_started_at + 'Z');@@@ -972,7 +972,7 @@ async function loadClipPlayer(clipId) {const extraDetails = document.getElementById('clp-extra-details');if (extraDetails) {let chips = '';-- if (cl.stream_category) chips += `<span class="detail-chip"><i class="fa-solid fa-tag"></i> ${esc(_capTag(cl.stream_category))}</span>`;++ if (cl.stream_category) chips += `<span class="detail-chip"${cl.stream_category_inferred ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}><i class="fa-solid fa-tag"></i> ${esc(_capTag(cl.stream_category))}${cl.stream_category_inferred ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>`;if (cl.stream_peak_viewers) chips += `<span class="detail-chip"><i class="fa-solid fa-users"></i> Peak: ${cl.stream_peak_viewers}</span>`;if (cl.stream_started_at) {const streamDate = new Date(cl.stream_started_at + 'Z');@@@ -11,7 +11,7 @@ let activeStreams = [];(so the dashboard paints instantly and only does the work you look at). Othermodules (e.g. moderation) register their loader on window._dashTabLoaders. */const _DASH_TAB_CARDS = {-- home: ['dash-card-profile', 'dash-card-offline'],++ home: ['dash-card-profile', 'dash-card-ai-derivation', 'dash-card-offline'],chatai: ['dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderationcontrols: ['dash-card-controls', 'dash-card-cameras', 'dash-card-tokens'],@@@ -95,7 +95,7 @@ window.switchDashTab = switchDashTab;// Per-tab loaders (guarded so a missing fn never breaks the tab).function _call(name) { try { if (typeof window[name] === 'function') window[name](); } catch (e) { console.warn('[dash]', name, e); } }--window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadSettingsOffline'); _call('loadDashFunds'); };++window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); };window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };@@@ -979,6 +979,31 @@ async function loadDashAiClips() {}function dashAiClipsGoPage(page) { dashAiClipsPage = page; loadDashAiClips(); }++/* ── AI Moments from my streams: the channel's AI derivation switch ──────────++ channels.ai_derivation_enabled (on by default). Off: the auto-clip, AI moments and caught-live++ jobs skip this channel and its after-show report is the stats-only one. */++async function loadAiDerivationSetting() {++ const box = document.getElementById('dash-ai-derivation');++ if (!box) return;++ try {++ const data = await api('/streams/channel');++ const ch = (data && data.channel) || data || {};++ box.checked = ch.ai_derivation_enabled == null || Number(ch.ai_derivation_enabled) !== 0;++ } catch { /* keep the default */ }++}++async function saveAiDerivationSetting(box) {++ const status = document.getElementById('dash-ai-derivation-status');++ const on = !!box.checked;++ try {++ await api('/streams/channel', { method: 'PUT', body: { ai_derivation_enabled: on ? 1 : 0 } });++ if (status) status.textContent = on ? 'On: the AI may make Moments from your streams.' : 'Off: the AI makes no new Moments from your streams.';++ toast(on ? 'AI Moments turned on for your channel' : 'AI Moments turned off for your channel', 'success');++ } catch (e) {++ box.checked = !on;++ toast(e.message || 'Save failed', 'error');++ }++}++async function loadClipSettings() {const el = document.getElementById('dash-clips-allow-creator-delete');if (!el) return;@@@ -9,7 +9,7 @@ let _offlineChannel = null;async function loadSettingsOffline() {try {const data = await api('/streams/channel');-- _offlineChannel = data.channel || {};++ _offlineChannel = (data && data.channel) || data || {}; // GET /streams/channel answers the channel row itselfconst type = _offlineChannel.offline_screen_type || 'none';document.getElementById('offline-screen-type').value = ['none', 'image', 'video', 'html'].includes(type) ? (type === 'video' ? 'image' : type) : 'none';document.getElementById('offline-html').value = _offlineChannel.offline_html || '';@@@ -267,7 +267,8 @@ function _dedupePastes() { /* moved to Media — sig-dedup happens pre-post */ }async function _momentPool(limit) {try {const r = await media.listVods({ limit, order: 'views' });-- const rows = r?.vods || (Array.isArray(r) ? r : []);++ // A channel that turned AI Moments off (channels.ai_derivation_enabled) is never picked from.++ const rows = (r?.vods || (Array.isArray(r) ? r : [])).filter((v) => { try { return db.isAiDerivationEnabled(v.user_id); } catch { return true; } });if (rows.length) {return rows.map((v) => {const state = (db.getVodAiState && db.getVodAiState(v.id)) || {};@@@ -495,7 +496,7 @@ function start() {setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);}--module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug };++module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, _internals: { momentPool: _momentPool } };// CLI: force a one-off regeneration, e.g. a whole-dataset "best of all-time" test run:// node server/ai/ai-moments-job.js --fresh --target=6 --perUser=2@@@ -38,6 +38,9 @@ let _timer = null;let _busy = false;function _aiOn() { return !!(ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()); }++// A streamer can turn AI Moments off for their channel (channels.ai_derivation_enabled, dashboard++// Home → AI Moments from my streams): nothing is cut from their streams, live or from VODs.++function _derivationOn(userId) { try { return db.isAiDerivationEnabled(userId); } catch { return true; } }function _clean(t, n) { return String(t || '').replace(/\s+/g, ' ').trim().slice(0, n || 300); }// ── Auto-clip log (replaces the old clips-table queries) ────@@@ -110,6 +113,7 @@ Return STRICT JSON only: {"clip": true|false, "title": "<specific punchy 3-8 worasync function _checkLiveStream(stream) {const streamId = stream.id;try {++ if (!_derivationOn(stream.user_id)) return;if (db.isStreamClipRecordingEnabled && !db.isStreamClipRecordingEnabled(stream)) return;// Hourly cap + min spacing.if (_countClipsSince(streamId, 60) >= MAX_PER_HOUR) return;@@@ -196,6 +200,7 @@ async function _backfillPool(limit) {for (const c of _clipLog()) { if (c.vod_id) clipped.add(c.vod_id); if (c.stream_id) clipped.add(`s${c.stream_id}`); }return rows.filter(v => v.stream_id && !clipped.has(v.id) && !clipped.has(`s${v.stream_id}`))++ .filter(v => _derivationOn(v.user_id)).filter(v => {try { return (db.get('SELECT COUNT(*) AS c FROM stream_memories WHERE stream_id = ?', [v.stream_id])?.c || 0) > 0; }catch { return false; }@@@ -288,6 +293,7 @@ async function clipVodMoment(o) {try {const { vod, offset, title, desc } = o || {};if (!vod || !(offset >= 0)) return null;++ if (!_derivationOn(vod.user_id)) return null; // the channel turned AI Moments offif (_isDuplicateAutoClip(vod, offset, desc, title)) {console.log(`[AutoClip] skip VOD ${vod.vod_id || vod.id} — duplicate/near-identical auto-clip already exists`);return null;@@@ -362,7 +368,7 @@ function stop() {if (_flagTimer) { _flagTimer(); _flagTimer = null; }}--module.exports = { start, stop, clipVodMoment, backfillVodClips, syncAutoClipFlags, _tick };++module.exports = { start, stop, clipVodMoment, backfillVodClips, syncAutoClipFlags, _tick, _internals: { checkLiveStream: _checkLiveStream, backfillPool: _backfillPool } };// CLI: force a historical backfill batch, e.g. `node server/ai/auto-clip-job.js --backfill --limit=4`if (require.main === module) {@@@ -185,6 +185,8 @@ const LIVE_PASTE_MIN_GAP_MS = 90 * 60 * 1000;async function _maybeLivePaste(stream, image, r, offset) {if (!r || !r.worthy || !r.title || !image) return;try { if (String(db.getSetting('ai_live_pastes_enabled') ?? 'true') === 'false') return; } catch { /* default on */ }++ // A channel that turned AI Moments off (channels.ai_derivation_enabled) gets no "caught live" pastes.++ try { if (!db.isAiDerivationEnabled(stream.user_id)) return; } catch { /* default on */ }const registry = require('./moment-registry');const last = registry.lastOfKind('paste', { stream_id: stream.id });if (last && Date.now() - (last.ts || 0) < LIVE_PASTE_MIN_GAP_MS) return;@@@ -240,4 +242,4 @@ function start() {console.log('[AI] Stream memory job started (30s poll; captures at the configured interval when enabled)');}--module.exports = { start, tick, captureMemoryNow };++module.exports = { start, tick, captureMemoryNow, _internals: { maybeLivePaste: _maybeLivePaste } };@@@ -552,6 +552,9 @@ function initDb() {// Tri-state preference for the About-tab AI overview: 'auto' (show only when there's no// bio/about yet), 'show' (always), 'hide' (never). Migrate old hide flag → 'hide'.add('ai_overview_pref', "ai_overview_pref TEXT DEFAULT 'auto'");++ // Whether OpenVibe's AI may make Moments from this channel's streams (auto-clips, AI moment++ // pastes, AI-written recaps). On unless the streamer turns it off (roadmap 33.7).++ add('ai_derivation_enabled', 'ai_derivation_enabled INTEGER DEFAULT 1');try { database.exec("UPDATE channels SET ai_overview_pref = 'hide' WHERE hide_ai_overview = 1 AND (ai_overview_pref IS NULL OR ai_overview_pref = 'auto')"); } catch { /* */ }} catch (e) { console.warn('[DB] Channel points config migration:', e.message); }@@@ -4176,6 +4179,18 @@ function _computeHomeStats() {function getChannelByUserId(userId) {return get('SELECT * FROM channels WHERE user_id = ?', [userId]);}++/**++ * Whether OpenVibe's AI may derive Moments (auto-clips, AI moment pastes, AI-written recaps) from++ * this account's streams: the channel's ai_derivation_enabled, on by default and for an account++ * with no channel row. Read by every job that makes them (roadmap 33.7).++ */++function isAiDerivationEnabled(userId) {++ if (userId == null || userId === '') return true;++ try {++ const row = get('SELECT ai_derivation_enabled FROM channels WHERE user_id = ?', [userId]);++ return !row || row.ai_derivation_enabled == null || Number(row.ai_derivation_enabled) !== 0;++ } catch { return true; }++}// Batched lookup → { userId: channelRow }. Avoids the N+1 in the live-streams list// (one query for all channels instead of one per stream).function getChannelsByUserIds(userIds) {@@@ -4231,7 +4246,7 @@ function updateChannel(userId, fields) {const updates = [];const params = [];for (const [key, val] of Object.entries(fields)) {-- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language'].includes(key)) {++ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled'].includes(key)) {updates.push(`${key} = ?`);params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);}@@@ -7497,7 +7512,7 @@ module.exports = {getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,getHomeStats,// Channels-- getChannelByUserId, getChannelsByUserIds, getChannelByUsername, createChannel, updateChannel, ensureChannel, setUserBio,++ getChannelByUserId, getChannelsByUserIds, getChannelByUsername, isAiDerivationEnabled, createChannel, updateChannel, ensureChannel, setUserBio,getChannelPointsConfig, setChannelPointsConfig,getChannelVodRecordingPolicyByUserId, resolveStreamRecordingMode, resolveStreamVodVisibility, resolveStreamClipVisibility, isStreamClipRecordingEnabled,// RobotStreamer integration@@@ -240,6 +240,7 @@ router.get('/:id', optionalAuth, async (req, res) => {clip.stream_ended_at = stream.ended_at;clip.stream_title = stream.title;clip.stream_category = stream.ai_category || stream.category;++ clip.stream_category_inferred = !!stream.ai_category; // shown as "inferred" (roadmap 33.4)clip.stream_peak_viewers = stream.peak_viewers;clip.stream_protocol = stream.protocol;}@@@ -474,6 +474,7 @@ router.get('/:id', optionalAuth, async (req, res) => {vod.stream_started_at = stream.started_at;vod.stream_ended_at = stream.ended_at;vod.stream_category = stream.ai_category || stream.category;++ vod.stream_category_inferred = !!stream.ai_category; // shown as "inferred" (roadmap 33.4)vod.stream_peak_viewers = stream.peak_viewers;}}@@@ -165,7 +165,10 @@ async function buildRecap(streamId, { ai = true } = {}) {ensureTable();const g = await gather(streamId);if (!g) return null;-- let write = ai ? await aiWriteup(g) : null;++ // A channel that turned AI Moments off gets the stats report only (no model, not an AI Moment).++ let derivationOn = true;++ try { derivationOn = db.isAiDerivationEnabled(g.streamer.id); } catch { /* default on */ }++ let write = ai && derivationOn ? await aiWriteup(g) : null;const usedAi = !!write;if (!write) write = templateWriteup(g);const recap = { ...g, write, ai: usedAi, generated_at: new Date().toISOString() };@@@ -1089,6 +1089,7 @@ function llmsTxt() {`- [AI Moments](${b}/moments): auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments`,'- AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap.','- An AI clip says "AI clip · from <streamer>\'s stream"; it is never presented as something the streamer or a viewer clipped.',++ '- A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.','','## API and docs','',@@@ -736,6 +736,10 @@ router.put('/channel', requireAuth, (req, res) => {const p = String(req.body.ai_overview_pref || 'auto').trim();if (['auto', 'show', 'hide'].includes(p)) fields.ai_overview_pref = p;}++ // Whether OpenVibe's AI may make Moments from this channel's streams (roadmap 33.7).++ if (hasOwn(req.body, 'ai_derivation_enabled')) {++ fields.ai_derivation_enabled = cleanBooleanFlag(req.body.ai_derivation_enabled) ? 1 : 0;++ }// Chat/stream language: 'auto' (detect from bio) or an ISO code from i18n.LANG_NAMES.if (hasOwn(req.body, 'chat_language')) {const l = String(req.body.chat_language || 'auto').trim().toLowerCase();nnew file mode 100644@@@ -0,0 +1,219 @@++/**++ * A streamer can turn AI Moments off for their channel (roadmap 33.7, 33.10 acceptance: "a creator++ * can disable derivation for a channel"). channels.ai_derivation_enabled, on by default, set from++ * the dashboard through PUT /api/streams/channel, is read by every job that makes Moments:++ * - the auto-clip job (live spikes, VOD backfill pool, clipVodMoment) cuts nothing;++ * - the AI moments job never picks the channel's VODs (so no moment pastes, no clips from them);++ * - the stream-memory job posts no "caught live" pastes;++ * - the after-show report is the stats-only one (no model call, not an AI Moment).++ * Channels that did not opt out are unaffected. The channel page's category pill says "inferred"++ * when the AI chose it (public/js/app-channel.js, run against a small stand-in DOM).++ *++ * node test/ai-derivation-optout.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const vm = require('vm');++++const tmp = path.join(os.tmpdir(), `ov-ai-optout-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++delete process.env.AI_SERVICE;++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++console.error = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++const auth = require('../server/auth/auth');++const signIn = (req) => { const id = Number(req.headers['x-test-user'] || 0); const u = id ? db.getUserById(id) : null; if (u) req.user = u; return u; };++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const addUser = (id, username) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)++ VALUES (?, ?, ?, ?, 'x', 'streamer', '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`);++addUser(3, 'alice'); // keeps AI Moments on (the default)++addUser(4, 'olive'); // opts out++addUser(5, 'nochan'); // no channel row at all++db.ensureChannel(3);++db.ensureChannel(4);++const mkStream = (userId, title) => {++ const ch = db.getChannelByUserId(userId);++ const id = Number(db.createStream({ user_id: userId, channel_id: ch.id, title, protocol: 'rtmp' }).lastInsertRowid);++ return id;++};++const sAlice = mkStream(3, 'Alice live'), sOlive = mkStream(4, 'Olive live');++for (const [sid, uid] of [[sAlice, 3], [sOlive, 4]]) db.addStreamMemory({ stream_id: sid, user_id: uid, offset_seconds: 60, description: 'a scene' });++++// ── Stand-ins: Media, Community, the recorder, the model ──++const media = require('../server/media-client');++const created = [];++media.createClip = async (body) => { created.push(body); return { id: 900 + created.length, status: 'processing' }; };++media.listVods = async () => ({ vods: [++ { id: 10, user_id: 3, stream_id: sAlice, title: 'Alice VOD', visibility: 'public', is_public: true, view_count: 5, duration_seconds: 600, status: 'ready' },++ { id: 11, user_id: 4, stream_id: sOlive, title: 'Olive VOD', visibility: 'public', is_public: true, view_count: 9, duration_seconds: 600, status: 'ready' },++] });++media.listClips = async () => ({ clips: [] });++media.getVod = async () => { throw new media.MediaApiError('stubbed', 0, null); };++media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };++const pastesClient = require('../server/pastes-client');++const posted = [];++pastesClient.createPaste = async (fields, opts) => { posted.push({ fields, opts }); return { slug: `p${posted.length}` }; };++const recorder = require('../server/streaming/recorder');++const recorderAsked = [];++recorder.getActiveRecording = (streamId) => { recorderAsked.push(streamId); return null; };++db.isStreamClipRecordingEnabled = () => true;++const llm = require('../server/ai/llm');++const modelCalls = [];++llm.isEnabled = () => true;++llm.withinBudget = () => true;++llm.complete = async (o) => { modelCalls.push(o); return { json: { headline: 'What a night', summary: 'Chat was loud.', moment: '', tags: ['loud'], grade: 'A' } }; };++++const autoClip = require('../server/ai/auto-clip-job');++const moments = require('../server/ai/ai-moments-job');++const memory = require('../server/ai/stream-memory-job');++const recap = require('../server/recap/recap');++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/streams', require('../server/streaming/routes'));++const server = http.createServer(app).listen(0);++function call(method, p, user, body) {++ return new Promise((resolve, reject) => {++ const headers = { 'content-type': 'application/json' };++ if (user) headers['x-test-user'] = String(user);++ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });++ });++ req.on('error', reject);++ if (body) req.write(JSON.stringify(body));++ req.end();++ });++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }++}++++(async () => {++ await new Promise((r) => server.once('listening', r));++ quiet('AI derivation opt-out');++++ await check('on by default, for every channel and for an account with no channel row', () => {++ const cols = raw.prepare("PRAGMA table_info('channels')").all();++ const col = cols.find((c) => c.name === 'ai_derivation_enabled');++ assert.ok(col, 'channels.ai_derivation_enabled exists');++ assert.strictEqual(String(col.dflt_value), '1');++ assert.strictEqual(db.isAiDerivationEnabled(3), true);++ assert.strictEqual(db.isAiDerivationEnabled(5), true);++ });++++ await check('the streamer turns it off (and on) from the dashboard: PUT /api/streams/channel', async () => {++ const off = await call('PUT', '/api/streams/channel', 4, { ai_derivation_enabled: 0 });++ assert.strictEqual(off.status, 200);++ assert.strictEqual(Number(off.json.channel.ai_derivation_enabled), 0);++ assert.strictEqual(db.isAiDerivationEnabled(4), false);++ const mine = await call('GET', '/api/streams/channel', 4);++ assert.strictEqual(Number(mine.json.ai_derivation_enabled), 0, 'the dashboard reads it back');++ await call('PUT', '/api/streams/channel', 3, { ai_derivation_enabled: 'true' });++ assert.strictEqual(db.isAiDerivationEnabled(3), true);++ const other = await call('PUT', '/api/streams/channel', 3, { title: 'x' });++ assert.strictEqual(other.status, 200);++ assert.strictEqual(db.isAiDerivationEnabled(4), false, "one streamer's save never touches another's");++ });++++ await check('the auto-clip job skips an opted-out live stream before touching its recording', async () => {++ recorderAsked.length = 0;++ await autoClip._internals.checkLiveStream(db.getStreamById(sOlive));++ assert.deepStrictEqual(recorderAsked, [], 'opted out: never looked');++ await autoClip._internals.checkLiveStream(db.getStreamById(sAlice));++ assert.deepStrictEqual(recorderAsked, [sAlice], 'opted in: checked as before');++ });++++ await check('the auto-clip job cuts nothing from an opted-out VOD, and still cuts for others', async () => {++ // (asked first: once a VOD has an auto-clip the pool skips it)++ const pool = await autoClip._internals.backfillPool(5);++ assert.deepStrictEqual(pool.map((v) => v.vod_id), [10], 'the VOD backfill pool leaves the opted-out channel out');++ created.length = 0;++ const none = await autoClip.clipVodMoment({ vod: { vod_id: 11, user_id: 4, stream_id: sOlive }, offset: 300, title: 'Olive moment' });++ assert.strictEqual(none, null);++ assert.strictEqual(created.length, 0);++ const clip = await autoClip.clipVodMoment({ vod: { vod_id: 10, user_id: 3, stream_id: sAlice }, offset: 300, title: 'Alice moment' });++ assert.ok(clip && clip.id, 'Alice still gets auto-clips');++ assert.strictEqual(created.length, 1);++ assert.strictEqual(created[0].auto_generated, true);++ });++++ await check("the AI moments job never picks an opted-out channel's VODs", async () => {++ const pool = await moments._internals.momentPool(10);++ assert.deepStrictEqual(pool.map((v) => v.vod_id), [10]);++ });++++ await check('no "caught live" paste from an opted-out stream', async () => {++ posted.length = 0;++ const frame = Buffer.alloc(4000, 7);++ const r = { worthy: true, title: 'Lights out', description: 'The lights go out mid-sentence.', tags: ['dark'] };++ await memory._internals.maybeLivePaste({ ...db.getStreamById(sOlive), username: 'olive' }, frame, r, 120);++ assert.strictEqual(posted.length, 0);++ await memory._internals.maybeLivePaste({ ...db.getStreamById(sAlice), username: 'alice' }, frame, r, 120);++ assert.strictEqual(posted.length, 1, 'opted in: posted as before');++ assert.strictEqual(posted[0].opts.origin, 'ai');++ });++++ await check('an opted-out channel gets the stats-only after-show report: no model call, not AI', async () => {++ db.endStream(sOlive); db.endStream(sAlice);++ modelCalls.length = 0;++ const out = await recap.buildRecap(sOlive);++ assert.ok(out, 'a report is still made');++ assert.strictEqual(out.ai, false);++ assert.strictEqual(modelCalls.length, 0);++ assert.strictEqual(raw.prepare('SELECT ai FROM stream_recaps WHERE stream_id = ?').get(sOlive).ai, 0);++ const theirs = await recap.buildRecap(sAlice);++ assert.strictEqual(theirs.ai, true, 'opted in: the AI writes it');++ assert.strictEqual(modelCalls.length, 1);++ });++++ await check('the channel page marks an AI-inferred category "inferred", and a chosen one plain', () => {++ const src = fs.readFileSync(path.join(__dirname, '../public/js/app-channel.js'), 'utf8');++ const start = src.indexOf('function _isInferredCategory(');++ const end = src.indexOf('\n}\n', src.indexOf('function _setCategoryBadge(')) + 3;++ class El {++ constructor() { this.children = []; this.attrs = {}; this.cls = new Set(); this.className = ''; this.classList = { toggle: (c, on) => (on ? this.cls.add(c) : this.cls.delete(c)) }; }++ set textContent(v) { this.children = [String(v)]; }++ get textContent() { return this.children.map((c) => (typeof c === 'string' ? c : c.textContent)).join(''); }++ append(n) { this.children.push(n); }++ set title(v) { this.attrs.title = v; }++ removeAttribute(k) { delete this.attrs[k]; }++ }++ const ctx = { document: { createElement: () => new El() }, _capTag: (x) => x.charAt(0).toUpperCase() + x.slice(1) };++ vm.createContext(ctx);++ vm.runInContext(`${src.slice(start, end)}\nthis.set = _setCategoryBadge; this.inferred = _isInferredCategory;`, ctx);++ const el = new El();++ ctx.set(el, 'gaming', ctx.inferred({ category: 'gaming', ai_category: 'gaming' }));++ assert.strictEqual(el.textContent, 'Gaming · inferred');++ assert.ok(el.cls.has('is-inferred') && /inferred by OpenVibe/.test(el.attrs.title));++ const chosen = new El();++ ctx.set(chosen, 'irl', ctx.inferred({ category: 'irl', ai_category: null }));++ assert.strictEqual(chosen.textContent, 'Irl');++ assert.ok(!chosen.cls.has('is-inferred') && !chosen.attrs.title);++ });++++ server.close();++ try { fs.unlinkSync(tmp); } catch { /* */ }++ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ quiet(failures ? `\n${failures} check(s) failed` : '\nAI derivation opt-out: all checks passed');++ process.exit(failures ? 1 : 0);++})();
@@ -1381,3 +1381,6 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -1381,3 +1381,6 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
font-size: 0.68rem; font-weight: 700; letter-spacing: .02em; color: #fff;
font-size: 0.68rem; font-weight: 700; letter-spacing: .02em; color: #fff;
background: linear-gradient(135deg, #6d28d9, #0e7490);
background: linear-gradient(135deg, #6d28d9, #0e7490);
}
}
+
+/* An AI-inferred category says so ("Gaming · inferred", roadmap 33.4). */
+.ch-category-badge .cat-inferred { font-weight: 500; opacity: .78; font-size: .9em; }
@@ -404,3 +404,4 @@
@@ -404,3 +404,4 @@
.clp-ai-attribution a { color: var(--accent); font-weight: 600; text-decoration: none; }
.clp-ai-attribution a { color: var(--accent); font-weight: 600; text-decoration: none; }
.clp-ai-attribution a:hover { text-decoration: underline; }
.clp-ai-attribution a:hover { text-decoration: underline; }
.clp-ai-how { font-size: 0.8rem; margin-top: 2px; }
.clp-ai-how { font-size: 0.8rem; margin-top: 2px; }
+.detail-chip .cat-inferred { opacity: .78; }
@@ -375,7 +375,7 @@
@@ -375,7 +375,7 @@
<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) -->
<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) -->
<div class="dash-card dash-card-wide" id="dash-card-aimoments">
<div class="dash-card dash-card-wide" id="dash-card-aimoments">
<h3 style="margin:0 0 6px"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments of My Stream</h3>
<h3 style="margin:0 0 6px"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments of My Stream</h3>
- <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them.</p>
+ <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home → AI Moments from my streams.</p>
<div id="dash-ai-clips" class="vod-grid"></div>
<div id="dash-ai-clips" class="vod-grid"></div>
</div>
</div>
@@ -495,6 +495,17 @@
@@ -495,6 +495,17 @@
</div>
</div>
</div>
</div>
+ <!-- AI Moments from my streams: the channel's AI derivation switch (roadmap 33.7) -->
+ <div class="dash-card dash-card-wide" id="dash-card-ai-derivation">
+ <h3><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments from my streams</h3>
+ <label class="staff-inline-toggle" style="margin-bottom:8px">
+ <input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)">
+ <span>Let OpenVibe's AI make Moments from my streams</span>
+ </label>
+ <p class="muted" style="font-size:0.85rem;margin:0">Moments are auto-clips cut when chat reacts, frames the AI picks and posts as notes, and AI-written after-show recaps. They are always labelled as AI-made, never credited to you, and kept out of search. Turned off, the AI makes no new ones from your streams (your after-show report keeps its stats). Moments already made stay until you delete them under Content → AI Moments.</p>
+ <span id="dash-ai-derivation-status" class="muted" style="font-size:0.82rem"></span>
+ </div>
+
<!-- Offline Screen (migrated from /settings) -->
<!-- Offline Screen (migrated from /settings) -->
<div class="dash-card dash-card-wide" id="dash-card-offline">
<div class="dash-card dash-card-wide" id="dash-card-offline">
<h3><i class="fa-solid fa-image"></i> Offline Screen</h3>
<h3><i class="fa-solid fa-image"></i> Offline Screen</h3>
@@ -28,6 +28,27 @@ function _updateMicOnlyOverlay(browserMode, streamingMethod) {
@@ -28,6 +28,27 @@ function _updateMicOnlyOverlay(browserMode, streamingMethod) {
container.appendChild(overlay);
container.appendChild(overlay);
}
}
let _activeChannelIsOwnerRank = false;
let _activeChannelIsOwnerRank = false;
+
+/**
+ * The category pill. A category the AI inferred from the stream (ai_category) says so, "Gaming ·
+ * inferred" (roadmap 33.4); one the streamer chose is shown plain. Stream rows carry `category` as
+ * the effective value (ai_category when there is one) and `ai_category` itself.
+ */
+function _isInferredCategory(row) { return !!(row && row.ai_category && row.ai_category === row.category); }
+function _setCategoryBadge(el, value, inferred) {
+ if (!el) return;
+ el.textContent = _capTag(value);
+ el.classList.toggle('is-inferred', !!inferred);
+ if (inferred) {
+ const mark = document.createElement('span');
+ mark.className = 'cat-inferred';
+ mark.textContent = ' · inferred';
+ el.append(mark);
+ el.title = "Category inferred by OpenVibe's AI from the stream";
+ } else {
+ el.removeAttribute('title');
+ }
+}
const CHANNEL_VODS_PAGE_SIZE = 12;
const CHANNEL_VODS_PAGE_SIZE = 12;
const CHANNEL_CLIPS_PAGE_SIZE = 12;
const CHANNEL_CLIPS_PAGE_SIZE = 12;
const channelVodsPageByUser = Object.create(null);
const channelVodsPageByUser = Object.create(null);
@@ -667,7 +688,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -667,7 +688,9 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
_activeChannelIsOwnerRank = !!ch.is_owner;
_activeChannelIsOwnerRank = !!ch.is_owner;
const _chUser = document.getElementById('ch-username');
const _chUser = document.getElementById('ch-username');
if (_chUser) _chUser.style.display = 'none';
if (_chUser) _chUser.style.display = 'none';
- document.getElementById('ch-category-badge').textContent = _capTag((liveStreams[0] && liveStreams[0].category) || ch.ai_category || ch.category || 'Live');
+ { const _ls0 = liveStreams[0], _liveCat = _ls0 && _ls0.category;
+ _setCategoryBadge(document.getElementById('ch-category-badge'), _liveCat || ch.ai_category || ch.category || 'Live',
+ _liveCat ? _isInferredCategory(_ls0) : !!ch.ai_category); }
document.getElementById('ch-follower-count').textContent = `${ch.follower_count || 0} followers`;
document.getElementById('ch-follower-count').textContent = `${ch.follower_count || 0} followers`;
setupFollowBtn(document.getElementById('ch-btn-follow'));
setupFollowBtn(document.getElementById('ch-btn-follow'));
setupBanBtn(document.getElementById('ch-btn-ban'));
setupBanBtn(document.getElementById('ch-btn-ban'));
@@ -728,7 +751,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -728,7 +751,7 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-username-offline').textContent = '@' + ch.username;
document.getElementById('ch-description-offline').textContent = ch.description || '';
document.getElementById('ch-description-offline').textContent = ch.description || '';
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
document.getElementById('ch-follower-count-offline').textContent = `${ch.follower_count || 0} followers`;
- document.getElementById('ch-category-badge-offline').textContent = _capTag(ch.ai_category || ch.category || 'Offline');
+ _setCategoryBadge(document.getElementById('ch-category-badge-offline'), ch.ai_category || ch.category || 'Offline', !!ch.ai_category);
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
setupFollowBtn(document.getElementById('ch-btn-follow-offline'));
setupBanBtn(document.getElementById('ch-btn-ban-offline'));
setupBanBtn(document.getElementById('ch-btn-ban-offline'));
@@ -1539,7 +1562,7 @@ function activateChannelStream(stream) {
@@ -1539,7 +1562,7 @@ function activateChannelStream(stream) {
setPageTitle(`${stream.title || 'Live'} — ${stream.display_name || stream.username || ''}`.trim());
setPageTitle(`${stream.title || 'Live'} — ${stream.display_name || stream.username || ''}`.trim());
// Category pill reflects THIS live slot's category (set in /broadcast), not the
// Category pill reflects THIS live slot's category (set in /broadcast), not the
// channel's stale default.
// channel's stale default.
- if (stream.category) { const _cb = document.getElementById('ch-category-badge'); if (_cb) _cb.textContent = _capTag(stream.category); }
+ if (stream.category) _setCategoryBadge(document.getElementById('ch-category-badge'), stream.category, _isInferredCategory(stream));
// Stream-type badge only (Screen Share / Audio Only / Camera). The WEBRTC/RTMP
// Stream-type badge only (Screen Share / Audio Only / Camera). The WEBRTC/RTMP
// protocol tag was removed from the header — that info now lives in the player's
// protocol tag was removed from the header — that info now lives in the player's
// stats overlay, which is more useful than the raw transport for viewers.
// stats overlay, which is more useful than the raw transport for viewers.
@@ -238,7 +238,7 @@ async function loadVodPlayer(vodId, seekTo) {
@@ -238,7 +238,7 @@ async function loadVodPlayer(vodId, seekTo) {
const extraDetails = document.getElementById('vp-extra-details');
const extraDetails = document.getElementById('vp-extra-details');
if (extraDetails) {
if (extraDetails) {
let chips = '';
let chips = '';
- if (v.stream_category) chips += `<span class="detail-chip"><i class="fa-solid fa-tag"></i> ${esc(_capTag(v.stream_category))}</span>`;
+ if (v.stream_category) chips += `<span class="detail-chip"${v.stream_category_inferred ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}><i class="fa-solid fa-tag"></i> ${esc(_capTag(v.stream_category))}${v.stream_category_inferred ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>`;
if (v.stream_peak_viewers) chips += `<span class="detail-chip"><i class="fa-solid fa-users"></i> Peak: ${v.stream_peak_viewers}</span>`;
if (v.stream_peak_viewers) chips += `<span class="detail-chip"><i class="fa-solid fa-users"></i> Peak: ${v.stream_peak_viewers}</span>`;
if (v.stream_started_at) {
if (v.stream_started_at) {
const streamDate = new Date(v.stream_started_at + 'Z');
const streamDate = new Date(v.stream_started_at + 'Z');
@@ -972,7 +972,7 @@ async function loadClipPlayer(clipId) {
@@ -972,7 +972,7 @@ async function loadClipPlayer(clipId) {
const extraDetails = document.getElementById('clp-extra-details');
const extraDetails = document.getElementById('clp-extra-details');
if (extraDetails) {
if (extraDetails) {
let chips = '';
let chips = '';
- if (cl.stream_category) chips += `<span class="detail-chip"><i class="fa-solid fa-tag"></i> ${esc(_capTag(cl.stream_category))}</span>`;
+ if (cl.stream_category) chips += `<span class="detail-chip"${cl.stream_category_inferred ? ' title="Category inferred by OpenVibe's AI from the stream"' : ''}><i class="fa-solid fa-tag"></i> ${esc(_capTag(cl.stream_category))}${cl.stream_category_inferred ? '<span class="cat-inferred"> · inferred</span>' : ''}</span>`;
if (cl.stream_peak_viewers) chips += `<span class="detail-chip"><i class="fa-solid fa-users"></i> Peak: ${cl.stream_peak_viewers}</span>`;
if (cl.stream_peak_viewers) chips += `<span class="detail-chip"><i class="fa-solid fa-users"></i> Peak: ${cl.stream_peak_viewers}</span>`;
if (cl.stream_started_at) {
if (cl.stream_started_at) {
const streamDate = new Date(cl.stream_started_at + 'Z');
const streamDate = new Date(cl.stream_started_at + 'Z');
@@ -11,7 +11,7 @@ let activeStreams = [];
@@ -11,7 +11,7 @@ let activeStreams = [];
(so the dashboard paints instantly and only does the work you look at). Other
(so the dashboard paints instantly and only does the work you look at). Other
modules (e.g. moderation) register their loader on window._dashTabLoaders. */
modules (e.g. moderation) register their loader on window._dashTabLoaders. */
const _DASH_TAB_CARDS = {
const _DASH_TAB_CARDS = {
- home: ['dash-card-profile', 'dash-card-offline'],
+ home: ['dash-card-profile', 'dash-card-ai-derivation', 'dash-card-offline'],
chatai: ['dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
chatai: ['dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
controls: ['dash-card-controls', 'dash-card-cameras', 'dash-card-tokens'],
controls: ['dash-card-controls', 'dash-card-cameras', 'dash-card-tokens'],
@@ -95,7 +95,7 @@ window.switchDashTab = switchDashTab;
@@ -95,7 +95,7 @@ window.switchDashTab = switchDashTab;
// Per-tab loaders (guarded so a missing fn never breaks the tab).
// Per-tab loaders (guarded so a missing fn never breaks the tab).
function _call(name) { try { if (typeof window[name] === 'function') window[name](); } catch (e) { console.warn('[dash]', name, e); } }
function _call(name) { try { if (typeof window[name] === 'function') window[name](); } catch (e) { console.warn('[dash]', name, e); } }
-window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadSettingsOffline'); _call('loadDashFunds'); };
+window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };
window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); };
window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); };
window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };
window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };
@@ -979,6 +979,31 @@ async function loadDashAiClips() {
@@ -979,6 +979,31 @@ async function loadDashAiClips() {
}
}
function dashAiClipsGoPage(page) { dashAiClipsPage = page; loadDashAiClips(); }
function dashAiClipsGoPage(page) { dashAiClipsPage = page; loadDashAiClips(); }
+/* ── AI Moments from my streams: the channel's AI derivation switch ──────────
+ channels.ai_derivation_enabled (on by default). Off: the auto-clip, AI moments and caught-live
+ jobs skip this channel and its after-show report is the stats-only one. */
+async function loadAiDerivationSetting() {
+ const box = document.getElementById('dash-ai-derivation');
+ if (!box) return;
+ try {
+ const data = await api('/streams/channel');
+ const ch = (data && data.channel) || data || {};
+ box.checked = ch.ai_derivation_enabled == null || Number(ch.ai_derivation_enabled) !== 0;
+ } catch { /* keep the default */ }
+}
+async function saveAiDerivationSetting(box) {
+ const status = document.getElementById('dash-ai-derivation-status');
+ const on = !!box.checked;
+ try {
+ await api('/streams/channel', { method: 'PUT', body: { ai_derivation_enabled: on ? 1 : 0 } });
+ if (status) status.textContent = on ? 'On: the AI may make Moments from your streams.' : 'Off: the AI makes no new Moments from your streams.';
+ toast(on ? 'AI Moments turned on for your channel' : 'AI Moments turned off for your channel', 'success');
+ } catch (e) {
+ box.checked = !on;
+ toast(e.message || 'Save failed', 'error');
+ }
+}
+
async function loadClipSettings() {
async function loadClipSettings() {
const el = document.getElementById('dash-clips-allow-creator-delete');
const el = document.getElementById('dash-clips-allow-creator-delete');
if (!el) return;
if (!el) return;
@@ -9,7 +9,7 @@ let _offlineChannel = null;
@@ -9,7 +9,7 @@ let _offlineChannel = null;
async function loadSettingsOffline() {
async function loadSettingsOffline() {
try {
try {
const data = await api('/streams/channel');
const data = await api('/streams/channel');
- _offlineChannel = data.channel || {};
+ _offlineChannel = (data && data.channel) || data || {}; // GET /streams/channel answers the channel row itself
const type = _offlineChannel.offline_screen_type || 'none';
const type = _offlineChannel.offline_screen_type || 'none';
document.getElementById('offline-screen-type').value = ['none', 'image', 'video', 'html'].includes(type) ? (type === 'video' ? 'image' : type) : 'none';
document.getElementById('offline-screen-type').value = ['none', 'image', 'video', 'html'].includes(type) ? (type === 'video' ? 'image' : type) : 'none';
document.getElementById('offline-html').value = _offlineChannel.offline_html || '';
document.getElementById('offline-html').value = _offlineChannel.offline_html || '';
@@ -267,7 +267,8 @@ function _dedupePastes() { /* moved to Media — sig-dedup happens pre-post */ }
@@ -267,7 +267,8 @@ function _dedupePastes() { /* moved to Media — sig-dedup happens pre-post */ }
async function _momentPool(limit) {
async function _momentPool(limit) {
try {
try {
const r = await media.listVods({ limit, order: 'views' });
const r = await media.listVods({ limit, order: 'views' });
- const rows = r?.vods || (Array.isArray(r) ? r : []);
+ // A channel that turned AI Moments off (channels.ai_derivation_enabled) is never picked from.
+ const rows = (r?.vods || (Array.isArray(r) ? r : [])).filter((v) => { try { return db.isAiDerivationEnabled(v.user_id); } catch { return true; } });
if (rows.length) {
if (rows.length) {
return rows.map((v) => {
return rows.map((v) => {
const state = (db.getVodAiState && db.getVodAiState(v.id)) || {};
const state = (db.getVodAiState && db.getVodAiState(v.id)) || {};
@@ -495,7 +496,7 @@ function start() {
@@ -495,7 +496,7 @@ function start() {
setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);
setInterval(() => { tick().catch(() => {}); }, 5 * 60 * 1000);
}
}
-module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug };
+module.exports = { start, tick, findBestMoment: _findBestMoment, frameTooDark: _frameTooDark, makeSlug: _slug, _internals: { momentPool: _momentPool } };
// CLI: force a one-off regeneration, e.g. a whole-dataset "best of all-time" test run:
// CLI: force a one-off regeneration, e.g. a whole-dataset "best of all-time" test run:
// node server/ai/ai-moments-job.js --fresh --target=6 --perUser=2
// node server/ai/ai-moments-job.js --fresh --target=6 --perUser=2
@@ -38,6 +38,9 @@ let _timer = null;
@@ -38,6 +38,9 @@ let _timer = null;
let _busy = false;
let _busy = false;
function _aiOn() { return !!(ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()); }
function _aiOn() { return !!(ai.isEnabled && ai.isEnabled() && ai.withinBudget && ai.withinBudget()); }
+// A streamer can turn AI Moments off for their channel (channels.ai_derivation_enabled, dashboard
+// Home → AI Moments from my streams): nothing is cut from their streams, live or from VODs.
+function _derivationOn(userId) { try { return db.isAiDerivationEnabled(userId); } catch { return true; } }
function _clean(t, n) { return String(t || '').replace(/\s+/g, ' ').trim().slice(0, n || 300); }
function _clean(t, n) { return String(t || '').replace(/\s+/g, ' ').trim().slice(0, n || 300); }
// ── Auto-clip log (replaces the old clips-table queries) ────
// ── Auto-clip log (replaces the old clips-table queries) ────
@@ -110,6 +113,7 @@ Return STRICT JSON only: {"clip": true|false, "title": "<specific punchy 3-8 wor
@@ -110,6 +113,7 @@ Return STRICT JSON only: {"clip": true|false, "title": "<specific punchy 3-8 wor
async function _checkLiveStream(stream) {
async function _checkLiveStream(stream) {
const streamId = stream.id;
const streamId = stream.id;
try {
try {
+ if (!_derivationOn(stream.user_id)) return;
if (db.isStreamClipRecordingEnabled && !db.isStreamClipRecordingEnabled(stream)) return;
if (db.isStreamClipRecordingEnabled && !db.isStreamClipRecordingEnabled(stream)) return;
// Hourly cap + min spacing.
// Hourly cap + min spacing.
if (_countClipsSince(streamId, 60) >= MAX_PER_HOUR) return;
if (_countClipsSince(streamId, 60) >= MAX_PER_HOUR) return;
@@ -196,6 +200,7 @@ async function _backfillPool(limit) {
@@ -196,6 +200,7 @@ async function _backfillPool(limit) {
for (const c of _clipLog()) { if (c.vod_id) clipped.add(c.vod_id); if (c.stream_id) clipped.add(`s${c.stream_id}`); }
for (const c of _clipLog()) { if (c.vod_id) clipped.add(c.vod_id); if (c.stream_id) clipped.add(`s${c.stream_id}`); }
return rows
return rows
.filter(v => v.stream_id && !clipped.has(v.id) && !clipped.has(`s${v.stream_id}`))
.filter(v => v.stream_id && !clipped.has(v.id) && !clipped.has(`s${v.stream_id}`))
+ .filter(v => _derivationOn(v.user_id))
.filter(v => {
.filter(v => {
try { return (db.get('SELECT COUNT(*) AS c FROM stream_memories WHERE stream_id = ?', [v.stream_id])?.c || 0) > 0; }
try { return (db.get('SELECT COUNT(*) AS c FROM stream_memories WHERE stream_id = ?', [v.stream_id])?.c || 0) > 0; }
catch { return false; }
catch { return false; }
@@ -288,6 +293,7 @@ async function clipVodMoment(o) {
@@ -288,6 +293,7 @@ async function clipVodMoment(o) {
try {
try {
const { vod, offset, title, desc } = o || {};
const { vod, offset, title, desc } = o || {};
if (!vod || !(offset >= 0)) return null;
if (!vod || !(offset >= 0)) return null;
+ if (!_derivationOn(vod.user_id)) return null; // the channel turned AI Moments off
if (_isDuplicateAutoClip(vod, offset, desc, title)) {
if (_isDuplicateAutoClip(vod, offset, desc, title)) {
console.log(`[AutoClip] skip VOD ${vod.vod_id || vod.id} — duplicate/near-identical auto-clip already exists`);
console.log(`[AutoClip] skip VOD ${vod.vod_id || vod.id} — duplicate/near-identical auto-clip already exists`);
return null;
return null;
@@ -362,7 +368,7 @@ function stop() {
@@ -362,7 +368,7 @@ function stop() {
if (_flagTimer) { _flagTimer(); _flagTimer = null; }
if (_flagTimer) { _flagTimer(); _flagTimer = null; }
}
}
-module.exports = { start, stop, clipVodMoment, backfillVodClips, syncAutoClipFlags, _tick };
+module.exports = { start, stop, clipVodMoment, backfillVodClips, syncAutoClipFlags, _tick, _internals: { checkLiveStream: _checkLiveStream, backfillPool: _backfillPool } };
// CLI: force a historical backfill batch, e.g. `node server/ai/auto-clip-job.js --backfill --limit=4`
// CLI: force a historical backfill batch, e.g. `node server/ai/auto-clip-job.js --backfill --limit=4`
if (require.main === module) {
if (require.main === module) {
@@ -185,6 +185,8 @@ const LIVE_PASTE_MIN_GAP_MS = 90 * 60 * 1000;
@@ -185,6 +185,8 @@ const LIVE_PASTE_MIN_GAP_MS = 90 * 60 * 1000;
async function _maybeLivePaste(stream, image, r, offset) {
async function _maybeLivePaste(stream, image, r, offset) {
if (!r || !r.worthy || !r.title || !image) return;
if (!r || !r.worthy || !r.title || !image) return;
try { if (String(db.getSetting('ai_live_pastes_enabled') ?? 'true') === 'false') return; } catch { /* default on */ }
try { if (String(db.getSetting('ai_live_pastes_enabled') ?? 'true') === 'false') return; } catch { /* default on */ }
+ // A channel that turned AI Moments off (channels.ai_derivation_enabled) gets no "caught live" pastes.
+ try { if (!db.isAiDerivationEnabled(stream.user_id)) return; } catch { /* default on */ }
const registry = require('./moment-registry');
const registry = require('./moment-registry');
const last = registry.lastOfKind('paste', { stream_id: stream.id });
const last = registry.lastOfKind('paste', { stream_id: stream.id });
if (last && Date.now() - (last.ts || 0) < LIVE_PASTE_MIN_GAP_MS) return;
if (last && Date.now() - (last.ts || 0) < LIVE_PASTE_MIN_GAP_MS) return;
@@ -240,4 +242,4 @@ function start() {
@@ -240,4 +242,4 @@ function start() {
console.log('[AI] Stream memory job started (30s poll; captures at the configured interval when enabled)');
console.log('[AI] Stream memory job started (30s poll; captures at the configured interval when enabled)');
}
}
-module.exports = { start, tick, captureMemoryNow };
+module.exports = { start, tick, captureMemoryNow, _internals: { maybeLivePaste: _maybeLivePaste } };
@@ -552,6 +552,9 @@ function initDb() {
@@ -552,6 +552,9 @@ function initDb() {
// Tri-state preference for the About-tab AI overview: 'auto' (show only when there's no
// Tri-state preference for the About-tab AI overview: 'auto' (show only when there's no
// bio/about yet), 'show' (always), 'hide' (never). Migrate old hide flag → 'hide'.
// bio/about yet), 'show' (always), 'hide' (never). Migrate old hide flag → 'hide'.
add('ai_overview_pref', "ai_overview_pref TEXT DEFAULT 'auto'");
add('ai_overview_pref', "ai_overview_pref TEXT DEFAULT 'auto'");
+ // Whether OpenVibe's AI may make Moments from this channel's streams (auto-clips, AI moment
+ // pastes, AI-written recaps). On unless the streamer turns it off (roadmap 33.7).
+ add('ai_derivation_enabled', 'ai_derivation_enabled INTEGER DEFAULT 1');
try { database.exec("UPDATE channels SET ai_overview_pref = 'hide' WHERE hide_ai_overview = 1 AND (ai_overview_pref IS NULL OR ai_overview_pref = 'auto')"); } catch { /* */ }
try { database.exec("UPDATE channels SET ai_overview_pref = 'hide' WHERE hide_ai_overview = 1 AND (ai_overview_pref IS NULL OR ai_overview_pref = 'auto')"); } catch { /* */ }
} catch (e) { console.warn('[DB] Channel points config migration:', e.message); }
} catch (e) { console.warn('[DB] Channel points config migration:', e.message); }
@@ -4176,6 +4179,18 @@ function _computeHomeStats() {
@@ -4176,6 +4179,18 @@ function _computeHomeStats() {
function getChannelByUserId(userId) {
function getChannelByUserId(userId) {
return get('SELECT * FROM channels WHERE user_id = ?', [userId]);
return get('SELECT * FROM channels WHERE user_id = ?', [userId]);
}
}
+/**
+ * Whether OpenVibe's AI may derive Moments (auto-clips, AI moment pastes, AI-written recaps) from
+ * this account's streams: the channel's ai_derivation_enabled, on by default and for an account
+ * with no channel row. Read by every job that makes them (roadmap 33.7).
+ */
+function isAiDerivationEnabled(userId) {
+ if (userId == null || userId === '') return true;
+ try {
+ const row = get('SELECT ai_derivation_enabled FROM channels WHERE user_id = ?', [userId]);
+ return !row || row.ai_derivation_enabled == null || Number(row.ai_derivation_enabled) !== 0;
+ } catch { return true; }
+}
// Batched lookup → { userId: channelRow }. Avoids the N+1 in the live-streams list
// Batched lookup → { userId: channelRow }. Avoids the N+1 in the live-streams list
// (one query for all channels instead of one per stream).
// (one query for all channels instead of one per stream).
function getChannelsByUserIds(userIds) {
function getChannelsByUserIds(userIds) {
@@ -4231,7 +4246,7 @@ function updateChannel(userId, fields) {
@@ -4231,7 +4246,7 @@ function updateChannel(userId, fields) {
const updates = [];
const updates = [];
const params = [];
const params = [];
for (const [key, val] of Object.entries(fields)) {
for (const [key, val] of Object.entries(fields)) {
- if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language'].includes(key)) {
+ if (val !== undefined && ['title', 'description', 'category', 'tags', 'protocol', 'is_nsfw', 'force_nsfw', 'auto_record', 'vod_recording_enabled', 'force_vod_recording_disabled', 'offline_banner_url', 'panels', 'emote_sources', 'weather_zip', 'weather_detail', 'weather_show_location', 'control_mode', 'anon_controls_enabled', 'control_rate_limit_ms', 'active_control_config_id', 'video_click_enabled', 'offline_screen_type', 'offline_screen_url', 'offline_html', 'offline_css', 'hide_ai_overview', 'ai_overview_pref', 'chat_language', 'ai_derivation_enabled'].includes(key)) {
updates.push(`${key} = ?`);
updates.push(`${key} = ?`);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
params.push(['tags', 'panels', 'emote_sources'].includes(key) ? (typeof val === 'string' ? val : JSON.stringify(val)) : val);
}
}
@@ -7497,7 +7512,7 @@ module.exports = {
@@ -7497,7 +7512,7 @@ module.exports = {
getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,
getRecentlyOnlineStreamers, countRecentlyOnlineStreamers,
getHomeStats,
getHomeStats,
// Channels
// Channels
- getChannelByUserId, getChannelsByUserIds, getChannelByUsername, createChannel, updateChannel, ensureChannel, setUserBio,
+ getChannelByUserId, getChannelsByUserIds, getChannelByUsername, isAiDerivationEnabled, createChannel, updateChannel, ensureChannel, setUserBio,
getChannelPointsConfig, setChannelPointsConfig,
getChannelPointsConfig, setChannelPointsConfig,
getChannelVodRecordingPolicyByUserId, resolveStreamRecordingMode, resolveStreamVodVisibility, resolveStreamClipVisibility, isStreamClipRecordingEnabled,
getChannelVodRecordingPolicyByUserId, resolveStreamRecordingMode, resolveStreamVodVisibility, resolveStreamClipVisibility, isStreamClipRecordingEnabled,
// RobotStreamer integration
// RobotStreamer integration
@@ -240,6 +240,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -240,6 +240,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
clip.stream_ended_at = stream.ended_at;
clip.stream_ended_at = stream.ended_at;
clip.stream_title = stream.title;
clip.stream_title = stream.title;
clip.stream_category = stream.ai_category || stream.category;
clip.stream_category = stream.ai_category || stream.category;
+ clip.stream_category_inferred = !!stream.ai_category; // shown as "inferred" (roadmap 33.4)
clip.stream_peak_viewers = stream.peak_viewers;
clip.stream_peak_viewers = stream.peak_viewers;
clip.stream_protocol = stream.protocol;
clip.stream_protocol = stream.protocol;
}
}
@@ -474,6 +474,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
@@ -474,6 +474,7 @@ router.get('/:id', optionalAuth, async (req, res) => {
vod.stream_started_at = stream.started_at;
vod.stream_started_at = stream.started_at;
vod.stream_ended_at = stream.ended_at;
vod.stream_ended_at = stream.ended_at;
vod.stream_category = stream.ai_category || stream.category;
vod.stream_category = stream.ai_category || stream.category;
+ vod.stream_category_inferred = !!stream.ai_category; // shown as "inferred" (roadmap 33.4)
vod.stream_peak_viewers = stream.peak_viewers;
vod.stream_peak_viewers = stream.peak_viewers;
}
}
}
}
@@ -165,7 +165,10 @@ async function buildRecap(streamId, { ai = true } = {}) {
@@ -165,7 +165,10 @@ async function buildRecap(streamId, { ai = true } = {}) {
ensureTable();
ensureTable();
const g = await gather(streamId);
const g = await gather(streamId);
if (!g) return null;
if (!g) return null;
- let write = ai ? await aiWriteup(g) : null;
+ // A channel that turned AI Moments off gets the stats report only (no model, not an AI Moment).
+ let derivationOn = true;
+ try { derivationOn = db.isAiDerivationEnabled(g.streamer.id); } catch { /* default on */ }
+ let write = ai && derivationOn ? await aiWriteup(g) : null;
const usedAi = !!write;
const usedAi = !!write;
if (!write) write = templateWriteup(g);
if (!write) write = templateWriteup(g);
const recap = { ...g, write, ai: usedAi, generated_at: new Date().toISOString() };
const recap = { ...g, write, ai: usedAi, generated_at: new Date().toISOString() };
@@ -1089,6 +1089,7 @@ function llmsTxt() {
@@ -1089,6 +1089,7 @@ function llmsTxt() {
`- [AI Moments](${b}/moments): auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments`,
`- [AI Moments](${b}/moments): auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments`,
'- AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap.',
'- AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap.',
'- An AI clip says "AI clip · from <streamer>\'s stream"; it is never presented as something the streamer or a viewer clipped.',
'- An AI clip says "AI clip · from <streamer>\'s stream"; it is never presented as something the streamer or a viewer clipped.',
+ '- A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.',
'',
'',
'## API and docs',
'## API and docs',
'',
'',
@@ -736,6 +736,10 @@ router.put('/channel', requireAuth, (req, res) => {
@@ -736,6 +736,10 @@ router.put('/channel', requireAuth, (req, res) => {
const p = String(req.body.ai_overview_pref || 'auto').trim();
const p = String(req.body.ai_overview_pref || 'auto').trim();
if (['auto', 'show', 'hide'].includes(p)) fields.ai_overview_pref = p;
if (['auto', 'show', 'hide'].includes(p)) fields.ai_overview_pref = p;
}
}
+ // Whether OpenVibe's AI may make Moments from this channel's streams (roadmap 33.7).
+ if (hasOwn(req.body, 'ai_derivation_enabled')) {
+ fields.ai_derivation_enabled = cleanBooleanFlag(req.body.ai_derivation_enabled) ? 1 : 0;
+ }
// Chat/stream language: 'auto' (detect from bio) or an ISO code from i18n.LANG_NAMES.
// Chat/stream language: 'auto' (detect from bio) or an ISO code from i18n.LANG_NAMES.
if (hasOwn(req.body, 'chat_language')) {
if (hasOwn(req.body, 'chat_language')) {
const l = String(req.body.chat_language || 'auto').trim().toLowerCase();
const l = String(req.body.chat_language || 'auto').trim().toLowerCase();
new file mode 100644
new file mode 100644
@@ -0,0 +1,219 @@
@@ -0,0 +1,219 @@
+/**
+ * A streamer can turn AI Moments off for their channel (roadmap 33.7, 33.10 acceptance: "a creator
+ * can disable derivation for a channel"). channels.ai_derivation_enabled, on by default, set from
+ * the dashboard through PUT /api/streams/channel, is read by every job that makes Moments:
+ * - the auto-clip job (live spikes, VOD backfill pool, clipVodMoment) cuts nothing;
+ * - the AI moments job never picks the channel's VODs (so no moment pastes, no clips from them);
+ * - the stream-memory job posts no "caught live" pastes;
+ * - the after-show report is the stats-only one (no model call, not an AI Moment).
+ * Channels that did not opt out are unaffected. The channel page's category pill says "inferred"
+ * when the AI chose it (public/js/app-channel.js, run against a small stand-in DOM).
+ *
+ * node test/ai-derivation-optout.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const vm = require('vm');
+
+const tmp = path.join(os.tmpdir(), `ov-ai-optout-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+delete process.env.AI_SERVICE;
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+console.error = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+const auth = require('../server/auth/auth');
+const signIn = (req) => { const id = Number(req.headers['x-test-user'] || 0); const u = id ? db.getUserById(id) : null; if (u) req.user = u; return u; };
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const addUser = (id, username) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
+ VALUES (?, ?, ?, ?, 'x', 'streamer', '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`);
+addUser(3, 'alice'); // keeps AI Moments on (the default)
+addUser(4, 'olive'); // opts out
+addUser(5, 'nochan'); // no channel row at all
+db.ensureChannel(3);
+db.ensureChannel(4);
+const mkStream = (userId, title) => {
+ const ch = db.getChannelByUserId(userId);
+ const id = Number(db.createStream({ user_id: userId, channel_id: ch.id, title, protocol: 'rtmp' }).lastInsertRowid);
+ return id;
+};
+const sAlice = mkStream(3, 'Alice live'), sOlive = mkStream(4, 'Olive live');
+for (const [sid, uid] of [[sAlice, 3], [sOlive, 4]]) db.addStreamMemory({ stream_id: sid, user_id: uid, offset_seconds: 60, description: 'a scene' });
+
+// ── Stand-ins: Media, Community, the recorder, the model ──
+const media = require('../server/media-client');
+const created = [];
+media.createClip = async (body) => { created.push(body); return { id: 900 + created.length, status: 'processing' }; };
+media.listVods = async () => ({ vods: [
+ { id: 10, user_id: 3, stream_id: sAlice, title: 'Alice VOD', visibility: 'public', is_public: true, view_count: 5, duration_seconds: 600, status: 'ready' },
+ { id: 11, user_id: 4, stream_id: sOlive, title: 'Olive VOD', visibility: 'public', is_public: true, view_count: 9, duration_seconds: 600, status: 'ready' },
+] });
+media.listClips = async () => ({ clips: [] });
+media.getVod = async () => { throw new media.MediaApiError('stubbed', 0, null); };
+media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };
+const pastesClient = require('../server/pastes-client');
+const posted = [];
+pastesClient.createPaste = async (fields, opts) => { posted.push({ fields, opts }); return { slug: `p${posted.length}` }; };
+const recorder = require('../server/streaming/recorder');
+const recorderAsked = [];
+recorder.getActiveRecording = (streamId) => { recorderAsked.push(streamId); return null; };
+db.isStreamClipRecordingEnabled = () => true;
+const llm = require('../server/ai/llm');
+const modelCalls = [];
+llm.isEnabled = () => true;
+llm.withinBudget = () => true;
+llm.complete = async (o) => { modelCalls.push(o); return { json: { headline: 'What a night', summary: 'Chat was loud.', moment: '', tags: ['loud'], grade: 'A' } }; };
+
+const autoClip = require('../server/ai/auto-clip-job');
+const moments = require('../server/ai/ai-moments-job');
+const memory = require('../server/ai/stream-memory-job');
+const recap = require('../server/recap/recap');
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/streams', require('../server/streaming/routes'));
+const server = http.createServer(app).listen(0);
+function call(method, p, user, body) {
+ return new Promise((resolve, reject) => {
+ const headers = { 'content-type': 'application/json' };
+ if (user) headers['x-test-user'] = String(user);
+ const req = http.request({ port: server.address().port, path: p, method, headers }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });
+ });
+ req.on('error', reject);
+ if (body) req.write(JSON.stringify(body));
+ req.end();
+ });
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }
+}
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+ quiet('AI derivation opt-out');
+
+ await check('on by default, for every channel and for an account with no channel row', () => {
+ const cols = raw.prepare("PRAGMA table_info('channels')").all();
+ const col = cols.find((c) => c.name === 'ai_derivation_enabled');
+ assert.ok(col, 'channels.ai_derivation_enabled exists');
+ assert.strictEqual(String(col.dflt_value), '1');
+ assert.strictEqual(db.isAiDerivationEnabled(3), true);
+ assert.strictEqual(db.isAiDerivationEnabled(5), true);
+ });
+
+ await check('the streamer turns it off (and on) from the dashboard: PUT /api/streams/channel', async () => {
+ const off = await call('PUT', '/api/streams/channel', 4, { ai_derivation_enabled: 0 });
+ assert.strictEqual(off.status, 200);
+ assert.strictEqual(Number(off.json.channel.ai_derivation_enabled), 0);
+ assert.strictEqual(db.isAiDerivationEnabled(4), false);
+ const mine = await call('GET', '/api/streams/channel', 4);
+ assert.strictEqual(Number(mine.json.ai_derivation_enabled), 0, 'the dashboard reads it back');
+ await call('PUT', '/api/streams/channel', 3, { ai_derivation_enabled: 'true' });
+ assert.strictEqual(db.isAiDerivationEnabled(3), true);
+ const other = await call('PUT', '/api/streams/channel', 3, { title: 'x' });
+ assert.strictEqual(other.status, 200);
+ assert.strictEqual(db.isAiDerivationEnabled(4), false, "one streamer's save never touches another's");
+ });
+
+ await check('the auto-clip job skips an opted-out live stream before touching its recording', async () => {
+ recorderAsked.length = 0;
+ await autoClip._internals.checkLiveStream(db.getStreamById(sOlive));
+ assert.deepStrictEqual(recorderAsked, [], 'opted out: never looked');
+ await autoClip._internals.checkLiveStream(db.getStreamById(sAlice));
+ assert.deepStrictEqual(recorderAsked, [sAlice], 'opted in: checked as before');
+ });
+
+ await check('the auto-clip job cuts nothing from an opted-out VOD, and still cuts for others', async () => {
+ // (asked first: once a VOD has an auto-clip the pool skips it)
+ const pool = await autoClip._internals.backfillPool(5);
+ assert.deepStrictEqual(pool.map((v) => v.vod_id), [10], 'the VOD backfill pool leaves the opted-out channel out');
+ created.length = 0;
+ const none = await autoClip.clipVodMoment({ vod: { vod_id: 11, user_id: 4, stream_id: sOlive }, offset: 300, title: 'Olive moment' });
+ assert.strictEqual(none, null);
+ assert.strictEqual(created.length, 0);
+ const clip = await autoClip.clipVodMoment({ vod: { vod_id: 10, user_id: 3, stream_id: sAlice }, offset: 300, title: 'Alice moment' });
+ assert.ok(clip && clip.id, 'Alice still gets auto-clips');
+ assert.strictEqual(created.length, 1);
+ assert.strictEqual(created[0].auto_generated, true);
+ });
+
+ await check("the AI moments job never picks an opted-out channel's VODs", async () => {
+ const pool = await moments._internals.momentPool(10);
+ assert.deepStrictEqual(pool.map((v) => v.vod_id), [10]);
+ });
+
+ await check('no "caught live" paste from an opted-out stream', async () => {
+ posted.length = 0;
+ const frame = Buffer.alloc(4000, 7);
+ const r = { worthy: true, title: 'Lights out', description: 'The lights go out mid-sentence.', tags: ['dark'] };
+ await memory._internals.maybeLivePaste({ ...db.getStreamById(sOlive), username: 'olive' }, frame, r, 120);
+ assert.strictEqual(posted.length, 0);
+ await memory._internals.maybeLivePaste({ ...db.getStreamById(sAlice), username: 'alice' }, frame, r, 120);
+ assert.strictEqual(posted.length, 1, 'opted in: posted as before');
+ assert.strictEqual(posted[0].opts.origin, 'ai');
+ });
+
+ await check('an opted-out channel gets the stats-only after-show report: no model call, not AI', async () => {
+ db.endStream(sOlive); db.endStream(sAlice);
+ modelCalls.length = 0;
+ const out = await recap.buildRecap(sOlive);
+ assert.ok(out, 'a report is still made');
+ assert.strictEqual(out.ai, false);
+ assert.strictEqual(modelCalls.length, 0);
+ assert.strictEqual(raw.prepare('SELECT ai FROM stream_recaps WHERE stream_id = ?').get(sOlive).ai, 0);
+ const theirs = await recap.buildRecap(sAlice);
+ assert.strictEqual(theirs.ai, true, 'opted in: the AI writes it');
+ assert.strictEqual(modelCalls.length, 1);
+ });
+
+ await check('the channel page marks an AI-inferred category "inferred", and a chosen one plain', () => {
+ const src = fs.readFileSync(path.join(__dirname, '../public/js/app-channel.js'), 'utf8');
+ const start = src.indexOf('function _isInferredCategory(');
+ const end = src.indexOf('\n}\n', src.indexOf('function _setCategoryBadge(')) + 3;
+ class El {
+ constructor() { this.children = []; this.attrs = {}; this.cls = new Set(); this.className = ''; this.classList = { toggle: (c, on) => (on ? this.cls.add(c) : this.cls.delete(c)) }; }
+ set textContent(v) { this.children = [String(v)]; }
+ get textContent() { return this.children.map((c) => (typeof c === 'string' ? c : c.textContent)).join(''); }
+ append(n) { this.children.push(n); }
+ set title(v) { this.attrs.title = v; }
+ removeAttribute(k) { delete this.attrs[k]; }
+ }
+ const ctx = { document: { createElement: () => new El() }, _capTag: (x) => x.charAt(0).toUpperCase() + x.slice(1) };
+ vm.createContext(ctx);
+ vm.runInContext(`${src.slice(start, end)}\nthis.set = _setCategoryBadge; this.inferred = _isInferredCategory;`, ctx);
+ const el = new El();
+ ctx.set(el, 'gaming', ctx.inferred({ category: 'gaming', ai_category: 'gaming' }));
+ assert.strictEqual(el.textContent, 'Gaming · inferred');
+ assert.ok(el.cls.has('is-inferred') && /inferred by OpenVibe/.test(el.attrs.title));
+ const chosen = new El();
+ ctx.set(chosen, 'irl', ctx.inferred({ category: 'irl', ai_category: null }));
+ assert.strictEqual(chosen.textContent, 'Irl');
+ assert.ok(!chosen.cls.has('is-inferred') && !chosen.attrs.title);
+ });
+
+ server.close();
+ try { fs.unlinkSync(tmp); } catch { /* */ }
+ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ quiet(failures ? `\n${failures} check(s) failed` : '\nAI derivation opt-out: all checks passed');
+ process.exit(failures ? 1 : 0);
+})();