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

OP OpenVibers committed 24 Sept 2026 · 2a7a0ae516 · 17 files, +332 −16


Changes

ddiff --git a/public/css/features/channel.css b/public/css/features/channel.css
iindex 98c4cbad..890bef0c 100644
---- a/public/css/features/channel.css
++++ b/public/css/features/channel.css
@@@ -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; }
ddiff --git a/public/css/features/media.css b/public/css/features/media.css
iindex 289d3e12..528926f4 100644
---- a/public/css/features/media.css
++++ b/public/css/features/media.css
@@@ -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; }
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex 70307223..51763ecb 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -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 &rarr; 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 &rarr; 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>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 1eb515bb..65adf916 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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, legacySessionI
document.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.
ddiff --git a/public/js/app-media.js b/public/js/app-media.js
iindex 19df6444..5423ba28 100644
---- a/public/js/app-media.js
++++ b/public/js/app-media.js
@@@ -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&#39;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&#39;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');
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex f9f6c8f7..2fe0f23d 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -11,7 +11,7 @@ let activeStreams = [];
(so the dashboard paints instantly and only does the work you look at). Other
modules (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-moderation
controls: ['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;
ddiff --git a/public/js/settings.js b/public/js/settings.js
iindex 62ecd2b1..3853f5c0 100644
---- a/public/js/settings.js
++++ b/public/js/settings.js
@@@ -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 itself
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-html').value = _offlineChannel.offline_html || '';
ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex b94e546d..6abf918b 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -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
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex cad2c646..69702a17 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -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 wor
async 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 off
if (_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) {
ddiff --git a/server/ai/stream-memory-job.js b/server/ai/stream-memory-job.js
iindex 96764afb..574d26d2 100644
---- a/server/ai/stream-memory-job.js
++++ b/server/ai/stream-memory-job.js
@@@ -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 } };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 86ad6d0c..0eb753d5 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 6c1f1423..dc81e042 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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;
}
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 5c440162..163dab1f 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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;
}
}
ddiff --git a/server/recap/recap.js b/server/recap/recap.js
iindex bb131e69..824c243c 100644
---- a/server/recap/recap.js
++++ b/server/recap/recap.js
@@@ -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() };
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex 4dc6d266..8c61ae1c 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -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',
'',
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex 1ae4ae16..ca2132b8 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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();
ddiff --git a/test/ai-derivation-optout.test.js b/test/ai-derivation-optout.test.js
nnew file mode 100644
iindex 00000000..bef4e744
---- /dev/null
++++ b/test/ai-derivation-optout.test.js
@@@ -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);
++})();