openvibers/openvibe.live Commits
Audio-only cards, content labels end to end, openvibe-shared 4.94.0 (#551)
Changes
@@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL<!-- versions:start -->- openvibe-contracts: v0.222.0- openvibe-sdk: v0.74.0--- openvibe-shared: v4.92.0++- openvibe-shared: v4.94.0<!-- versions:end -->@@@ -7,7 +7,7 @@"domain": "openvibe.live (www, ingest, whip)","contracts": "openvibe-contracts v0.222.0","sdk": "openvibe-sdk v0.74.0",-- "shared": "openvibe-shared v4.92.0",++ "shared": "openvibe-shared v4.94.0","capabilities": ["live.chat_context.read","live.chat_effects.write",nnew file mode 100644@@@ -0,0 +1,17 @@++-- phase: expand++-- Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).++--++-- A creator labels a stream slot (managed_streams) in the setup/dashboard; the live session copies those labels when++-- it starts, so the channel page, the cards and the watch screen read them from the stream itself. The AI may++-- SUGGEST a label from the frame notes it already writes; a suggestion is stored in `suggested` and changes nothing++-- for anyone until the creator confirms it. Three viewer suggestions or a moderator may put a label on later.++--++-- Labels are choices, not bans: nothing here hides content from the person who posted it, and nothing filters a++-- viewer's search or feed except the one clean page (the home page skips 18+). The query that reads a label uses the++-- jsonb containment operator, so the GIN index answers it.++--++-- Expand only: an older release never reads these columns, and the defaults keep every existing row label-free.++ALTER TABLE managed_streams ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;++ALTER TABLE streams ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;++CREATE INDEX IF NOT EXISTS streams_content_labels ON streams USING gin (content_labels);++CREATE INDEX IF NOT EXISTS managed_streams_content_labels ON managed_streams USING gin (content_labels);@@@ -25,7 +25,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.92.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2884,9 +2884,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.92.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.92.0",-- "integrity": "sha512-Z20rSXMAKef0d4VfqsFkJ76u4uIS8TiCZ5/Dqmpp00keYoGKz33qzykjpWraUuShmRQJdXzlGtmcAHdiWCxvOA==",++ "version": "4.94.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",++ "integrity": "sha512-iMsgL7a5vxouygXsWxQACJrnRubhpe6tnMAKjiJZ0m6Fn+hFcJjQzrI/fERZB3ToC+xK4zO6b6hsm4zK6x4MYg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -44,7 +44,7 @@"onnxruntime-node": "^1.20.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.92.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -3327,3 +3327,20 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna.gl-test-done:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }@media (pointer: coarse) { .gl-test-done { min-height: 44px; } }@media (prefers-reduced-motion: reduce) { .gl-test-btn.is-rec i { animation: none; } }++++/* Labels (L1) in the go-live card's fold: six toggles; the AI's suggestions dashed, to confirm. */++.gl-labels-why { margin: 0 0 10px; font-size: .82rem; line-height: 1.45; color: var(--text-secondary); }++.gl-labels, .gl-labels-ai { display: flex; flex-wrap: wrap; gap: 6px; }++.gl-labels-ai { margin-top: 12px; }++.gl-labels-ai:empty { display: none; }++.gl-labels-ai-h { flex-basis: 100%; margin: 0 0 2px; font-size: .78rem; color: var(--text-muted); }++.gl-label { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border-radius: 999px; cursor: pointer;++ font-size: .84rem; font-weight: 650; color: var(--text-secondary); border: 1px solid var(--border); background: color-mix(in srgb, var(--text-primary) 4%, transparent);++ transition: background-color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s); }++.gl-label:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }++.gl-label:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.gl-label.is-on { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); background: color-mix(in srgb, var(--accent) 16%, transparent); }++.gl-label--adult.is-on { color: var(--warning-text, var(--warning)); border-color: color-mix(in srgb, var(--warning-text, var(--warning)) 55%, var(--border)); background: color-mix(in srgb, var(--warning-text, var(--warning)) 10%, transparent); }++.gl-label--ai { border-style: dashed; color: var(--accent); }++@media (pointer: coarse) { .gl-label { min-height: 44px; } }++@media (prefers-reduced-motion: reduce) { .gl-label { transition: none; } }@@@ -901,6 +901,20 @@ body.ov-nav-overlay .navbar.ovnav-top {font-size: 0.65rem; font-weight: 700;}++/* Audio-only (Media#240/R4): the picture is Media's avatar card and the card says why there is no frame. */++.stream-card-audio {++ position: absolute; top: 8px; left: 8px;++ display: inline-flex; align-items: center; gap: 4px;++ padding: 2px 7px; border-radius: 999px;++ background: color-mix(in srgb, var(--accent) 22%, rgba(9, 9, 13, 0.72));++ border: 1px solid rgba(255, 255, 255, 0.18);++ color: #fff; font-size: 0.65rem; font-weight: 700; letter-spacing: 0.01em;++ backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);++ pointer-events: none;++}++.stream-card-audio i { font-size: 0.78em; opacity: 0.95; }++@container (max-width: 150px) { .stream-card-audio { top: 4px; left: 4px; padding: 1px 5px; font-size: 0.6rem; } }++/* Protocol badges */.protocol-badge {display: inline-block;@@@ -10002,3 +10016,10 @@ body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transfhtml.ov-has-appbar .messenger-toggle { bottom: calc(var(--ovnav-appbar-h, 58px) + 14px); }html.ov-has-appbar body { padding-bottom: calc(var(--ovnav-appbar-h, 58px) + 64px); }}++++/* Content labels (L1): quiet chips in words, the adult one in the warning colour (as Shared's kit.labels). */++.ovk-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; padding: 0; list-style: none; }++.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;++ color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 7%, transparent); border: 1px solid var(--border); }++.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }++.stream-labels:empty { display: none; }@@@ -121,6 +121,7 @@<span id="ch-protocol-badge"></span></div><p class="ch-stream-title" id="ch-stream-title">Stream Title</p>++ <div id="stream-labels" class="stream-labels"></div><p class="ch-stream-description muted" id="ch-stream-description" style="display:none"></p><div class="ch-stream-ai" id="ch-stream-ai" style="display:none"></div><div class="ch-cumulative-viewers" id="ch-cumulative-viewers" style="display:none"></div>@@@ -167,6 +167,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})<a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')" style="border:2px solid var(--accent);position:relative"><div class="stream-card-thumb">${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}++ ${audioChip(v)}<span class="stream-card-nsfw" style="background:#e53e3e;animation:pulse 2s infinite">● RECORDING</span><span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || 0)}</span></div>@@@ -188,6 +189,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})<div class="stream-card-thumb">${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}${_visBadge(v.visibility, v.is_public, canManage)}++ ${audioChip(v)}${v.stream_protocol ? protocolBadge(v.stream_protocol) : ''}<span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || v.duration)}</span></div>@@@ -1646,6 +1646,7 @@ async function loadHomeRecentVods(opts) {<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')"><div class="stream-card-thumb">${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}++ ${audioChip(v)}${v.duration_seconds ? `<span class="stream-card-duration">${formatDuration(v.duration_seconds)}</span>` : ''}${ovViewsBadge(v.view_count)}</div>@@@ -1683,6 +1684,7 @@ async function loadHomeClips(opts) {<a class="stream-card" href="${videoHref('clip', c.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', c.id)}')"><div class="stream-card-thumb">${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}++ ${audioChip(c)}${ovViewsBadge(c.view_count)}${c.duration_seconds ? `<span class="stream-card-duration">${formatDuration(c.duration_seconds)}</span>` : ''}</div>@@@ -1885,8 +1887,8 @@ function streamCardHTML(s, isLive) {const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();const shown = topics.filter((t) => t !== cat).slice(0, 2);return `-- <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">-- <div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">++ <a class="stream-card"${ovLabelsAttr(s)}${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">++ <div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}${streamTypeBadge(s.browser_mode, s.streaming_method)}${s.is_nsfw ? '<span class="stream-card-nsfw">18+</span>' : ''}@@@ -1894,6 +1896,7 @@ function streamCardHTML(s, isLive) {</div><div class="stream-card-info"><div class="stream-card-title">${esc(s.title || 'Untitled Stream')}</div>++ ${ovLabelChips(s)}<div class="stream-card-streamer">${_avatarSpan(s.avatar_url, s.username, s.profile_color)}${esc(s.username || 'Anonymous')}@@@ -2132,6 +2132,12 @@ function thumbImg(thumbnailUrl, fallbackIcon, alt, regenerateUrl = null) {return `<i class="fa-solid ${fallbackIcon}"></i>`;}++/** An audio-only recording or clip (Media#240/R4): its picture is Media's avatar card, and the card says so. */++function audioChip(item) {++ if (!item || !(item.audio_only === true || item.audio_only === 1)) return '';++ return '<span class="stream-card-audio" title="Audio only"><i class="fa-solid fa-microphone" aria-hidden="true"></i> Audio</span>';++}++function formatDuration(secs) {if (!secs) return '0:00';const h = Math.floor(secs / 3600);@@@ -2477,3 +2483,38 @@ window.addEventListener('popstate', () => {// If auth hasn't loaded yet, DOMContentLoaded handler will call routeFromURL()});++++++/* ── Content labels (L1): what a stream or recording carries, for Shared's ov-labels.js (each viewer's show, blur or++ hide) and as chips in words. The streamer's own content always shows to them, so it is never marked for them. ── */++const OV_LABEL_NAMES = { nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' };++function ovLabelsOf(item) {++ let raw = item && item.content_labels;++ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }++ const list = raw && Array.isArray(raw.labels) ? raw.labels : [];++ return [...new Set(list)].filter((l) => Object.prototype.hasOwnProperty.call(OV_LABEL_NAMES, l));++}++function ovIsMine(item) {++ try { return !!(currentUser && item && item.username && String(currentUser.username).toLowerCase() === String(item.username).toLowerCase()); } catch { return false; }++}++/** ' data-ov-labels="…"' for a card or player (empty for none, or for the viewer's own). */++function ovLabelsAttr(item) {++ const labels = ovLabelsOf(item);++ return labels.length && !ovIsMine(item) ? ` data-ov-labels="${esc(labels.join(','))}"` : '';++}++/** The chips, in words; 18+ in the warning colour. */++function ovLabelChips(item) {++ const labels = ovLabelsOf(item);++ if (!labels.length) return '';++ return `<ul class="ovk-labels" aria-label="Content labels">${labels.map((l) => `<li class="ovk-label${l === '18+' ? ' ovk-label--adult' : ''}" data-label="${esc(l)}">${esc(OV_LABEL_NAMES[l])}</li>`).join('')}</ul>`;++}++/** Mark (or unmark) an element already on the page, such as the channel's player. */++function ovMarkLabels(el, item, { opened = false } = {}) {++ if (!el) return;++ const labels = ovIsMine(item) ? [] : ovLabelsOf(item);++ if (labels.length) el.setAttribute('data-ov-labels', labels.join(','));++ else { el.removeAttribute('data-ov-labels'); el.removeAttribute('data-ov-labels-state'); }++ // The thing's own page (the channel's player): hide covers it rather than emptying the page (openvibe-shared 4.94.0).++ el.toggleAttribute('data-ov-labels-opened', Boolean(opened && labels.length));++ if (window.OVLabels && typeof window.OVLabels.apply === 'function') window.OVLabels.apply(el);++}@@@ -310,6 +310,9 @@ function _wsRenderPanel() {<input type="checkbox" id="bc-nsfw" ${ms.is_nsfw ? 'checked' : ''} onchange="_wsMarkDirty()"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i><span>Mature</span><b id="gl-mature-v">${ms.is_nsfw ? 'on' : 'off'}</b></label>++ <button type="button" class="gl-chip" onclick="_glOpenFold('gl-labels')" title="What this stream carries: each viewer decides to show, blur or hide it">++ <i class="fa-solid fa-tag" aria-hidden="true"></i><span>Labels</span><b id="gl-labels-v">${esc(_glLabelsSummary(ms, true))}</b>++ </button><button type="button" class="gl-chip gl-chip--link" onclick="_glCopyLink()" title="Copy your stream's address"><i class="fa-solid fa-link" aria-hidden="true"></i><span id="gl-link">${esc(link)}</span></button>@@@ -439,6 +442,15 @@ function _wsRenderPanel() {</div></details>++ <details class="gl-fold" id="gl-labels">++ <summary><i class="fa-solid fa-tag" aria-hidden="true"></i><span class="bc-sum-title">Labels</span><small class="bc-sum-val" id="gl-labels-sum">${esc(_glLabelsSummary(ms))}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>++ <div class="gl-fold-body">++ <p class="gl-labels-why">Labels are choices, not bans. Each viewer decides whether what carries them shows, blurs until tapped or hides; the home page leaves 18+ out.</p>++ <div class="gl-labels" id="gl-labels-pick" role="group" aria-label="Labels on this stream">${_glLabelsChips(ms)}</div>++ <div class="gl-labels-ai" id="gl-labels-ai">${_glLabelsSuggested(ms)}</div>++ </div>++ </details>++<details class="gl-fold" id="gl-how"><summary><i class="fa-solid fa-${_glMethodIcon(method, browserMode)}" aria-hidden="true"></i><span class="bc-sum-title">How you stream</span><small class="bc-sum-val" id="gl-how-sum">${esc(_glHowSummary(method, browserMode))}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary><div class="gl-fold-body bc-ws-col--method">@@@ -922,6 +934,8 @@ function _glSyncSummaries() {if (!ms) return;const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };set('gl-details-sum', _glDetailsSummary(ms));++ set('gl-labels-sum', esc(_glLabelsSummary(ms)));++ set('gl-labels-v', esc(_glLabelsSummary(ms, true)));const method = ms.streaming_method || 'browser', mode = ms.browser_mode || _wsState.profile?.browserMode || 'camera';set('gl-how-sum', esc(_glHowSummary(method, mode)));const howIcon = document.querySelector('#gl-how > summary > i:first-child');@@@ -1280,6 +1294,51 @@ function _glTestClear() {if (_glT.url) { URL.revokeObjectURL(_glT.url); _glT.url = ''; }}++/* ── Labels on the stream (L1, Live #477, common.content-labels@1): the creator sets them; the AI only suggests, and a++ suggestion is the creator's to confirm. Saved with the rest of the card (_wsSaveAll sends labels). ── */++const _GL_LABELS = [['nudity', 'Nudity'], ['language', 'Strong language'], ['graphic', 'Graphic'], ['18+', '18+'], ['flashing', 'Flashing lights'], ['spoilers', 'Spoilers']];++const _glLabelName = (id) => (_GL_LABELS.find(([k]) => k === id) || [id, id])[1];++++function _glLabelsOf(ms) {++ let raw = ms && ms.content_labels;++ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }++ const pick = (v) => (Array.isArray(v) ? v : []).filter((l) => _GL_LABELS.some(([k]) => k === l));++ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };++}++++function _glLabelsSummary(ms, short = false) {++ const { labels, suggested } = _glLabelsOf(ms);++ if (!labels.length) return suggested.length && !short ? `None · ${suggested.length} suggested` : 'none';++ if (short && labels.length > 2) return `${labels.length}`;++ return labels.map(_glLabelName).join(', ');++}++++function _glLabelsChips(ms) {++ const { labels } = _glLabelsOf(ms);++ return _GL_LABELS.map(([id, name]) => `<button type="button" class="gl-label${labels.includes(id) ? ' is-on' : ''}${id === '18+' ? ' gl-label--adult' : ''}" aria-pressed="${labels.includes(id)}" onclick="_glToggleLabel('${id}')">${esc(name)}</button>`).join('');++}++++function _glLabelsSuggested(ms) {++ const { labels, suggested } = _glLabelsOf(ms);++ const open = suggested.filter((l) => !labels.includes(l));++ if (!open.length) return '';++ return `<p class="gl-labels-ai-h">The AI noticed these on your last stream. Nothing changes until you add one.</p>`++ + open.map((id) => `<button type="button" class="gl-label gl-label--ai" onclick="_glToggleLabel('${id}')">+ ${esc(_glLabelName(id))}</button>`).join('');++}++++function _glToggleLabel(id) {++ const ms = _wsState.selectedMs;++ if (!ms || !_GL_LABELS.some(([k]) => k === id)) return;++ const cur = _glLabelsOf(ms);++ const labels = cur.labels.includes(id) ? cur.labels.filter((l) => l !== id) : [...cur.labels, id];++ ms.content_labels = { labels, suggested: cur.suggested.filter((l) => !labels.includes(l)) };++ const pick = document.getElementById('gl-labels-pick');++ if (pick) pick.innerHTML = _glLabelsChips(ms);++ const ai = document.getElementById('gl-labels-ai');++ if (ai) ai.innerHTML = _glLabelsSuggested(ms);++ _wsMarkDirty();++}++/* ── Tab switching ───────────────────────────────────────────── */function _wsTabSwitch(tabId) {@@@ -2146,6 +2205,8 @@ async function _wsSaveAll() {weather_zip: ms.weather_zip || null,weather_detail: ms.weather_detail || 'basic',weather_show_location: ms.weather_show_location || 0,++ // The creator's labels (L1): confirmed ones leave the AI's suggestions on the server too.++ labels: _glLabelsOf(ms).labels,};const profileBlob = {@@@ -960,6 +960,13 @@ function initPlayer(stream) {try { window.streamPip?.attach(stream); } catch (e) { console.warn('[PiP] attach failed:', e.message); }setViewerPausedIntent(false); // fresh stream — always start playingstreamRef = stream; // Store for clip recording across all protocols++ // Content labels (L1): each viewer's own choice covers or hides the player (Shared ov-labels.js), and the chips++ // under the title say what it carries.++ try {++ ovMarkLabels(document.getElementById('video-container'), stream, { opened: true });++ const chips = document.getElementById('stream-labels');++ if (chips) chips.innerHTML = ovLabelChips(stream);++ } catch { /* labels are a courtesy: never stop playback */ }_resetPlayerRevealState();_applyLoaderThumb(stream);const proto = stream.protocol || 'jsmpeg';@@@ -22,8 +22,8 @@ const BUDGETS = {htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 beforehtmlBrotliKB: 30,jsFiles: 26, // measured 23 (incl. route scripts for "/")-- jsRawKB: 1258, // 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)-- jsBrotliKB: 294.4, // 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)++ jsRawKB: 1263, // 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)++ jsBrotliKB: 295.5, // 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)cssBrotliKB: 55, // measured ~47.5 (was ~130)};@@@ -199,7 +199,7 @@ async function _generateVodOverviewInner(vod) {if (!src) { try { await db.setVodAiOverview(vod.id, ' '); } catch { /* */ } return null; } // unprocessable — mark doneconst r = await ma.analyzeMedia(src, {streamId: vod.stream_id || null, userId: vod.user_id || null,-- storeMemories: !!vod.stream_id, offsetBase: 0,++ storeMemories: !!vod.stream_id, offsetBase: 0, audioOnly: !!vod.audio_only,});const overview = r && r.overview ? r.overview : ' '; // ' ' = tried, nothing to saytry { await db.setVodAiOverview(vod.id, overview); } catch { /* */ }@@@ -219,6 +219,7 @@ async function _generateVodOverviewInner(vod) {*/async function ensureVodTimeline(vod) {if (!vod || !vod.stream_id) return;++ if (vod.audio_only) return; // no frames to anchor (Media#240/R4); the transcript pass carries the momentsif (require('./ai-media-analyze').enabled()) return;if (!await isEnabled() || !await withinBudget()) return;const ma = require('./media-analysis');@@@ -354,6 +355,7 @@ async function generateClipOverview(clip) {const r = await require('./media-analysis').analyzeMedia(src, {streamId: clip.stream_id || null, userId: clip.user_id || null,numFrames: 3, storeMemories: !!clip.stream_id, offsetBase: clip.start_time || 0,++ audioOnly: !!clip.audio_only,});const overview = (r && r.overview) ? r.overview : ' ';const transcript = r ? r.transcript : '';@@@ -315,13 +315,16 @@ async function _transcribeSpan(src, duration, { resumeFromSec = 0, priorSegments* descriptions (optionally stored as memories) + sampled transcript → overview.* @returns {{ overview, transcript, segments, transcriptOk, frames, duration }}*/--async function analyzeMedia(src, { streamId = null, userId = null, numFrames = null, storeMemories = false, offsetBase = 0 } = {}) {++async function analyzeMedia(src, { streamId = null, userId = null, numFrames = null, storeMemories = false, offsetBase = 0, audioOnly = false } = {}) {if (!src) return { overview: null, transcript: '', frames: [], duration: 0 };// Lazy require (avoids a load-time circular dep with ai-analysis, which pulls us in).const ai = require('./ai-analysis');const duration = await _ffprobeDuration(src);let frames = [];-- if (duration <= 2) {++ // An audio-only recording has no frame to take (Media#240/R4): skip frame extraction entirely instead of asking++ // ffmpeg for pictures that do not exist on every retry. The transcript is the whole analysis.++ if (audioOnly) frames = [];++ else if (duration <= 2) {// Tiny media — one frame.const one = await captureFrameMemories(src, [1], { streamId, userId, offsetBase, store: storeMemories });frames = one;@@@ -9,6 +9,7 @@*/const db = require('../db/database');const ai = require('./ai-analysis');++const contentLabels = require('../labels');let vision = null;try { vision = require('./stream-vision'); } catch { /* optional */ }@@@ -150,6 +151,9 @@ async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } =description: memDesc, tags: r.tags, thumbnail_url: await _persistMomentFrame(image, stream.id, offset) || _stableThumb(stream.thumbnail_url),transcript_json: heardSegments,});++ // L1: suggest labels from what this frame shows and what was said. A suggestion is stored in `suggested`++ // and changes nothing for anyone; the creator confirms it (PUT /api/streams/:id). Never a takedown.++ try { await db.suggestStreamContentLabels(stream.id, contentLabels.suggestFrom({ description: r.description, transcript: heardClean })); } catch (e) { console.warn('[AI-Labels] suggestion not stored:', e.message); }// Roll this stream's memories into an overview — but only RE-summarize when// enough new memories have accumulated (SUMMARY_MIN_NEW) or enough time has// passed (SUMMARY_MAX_AGE_MS), NOT on every 120s frame capture. This cuts the@@@ -128,6 +128,7 @@ async function vodItem(v) {created_at: iso(v.created_at), views: num(v.view_count),duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),thumbnail_url: media.publicUrl(v.thumbnail_url) || null,++ audio_only: !!v.audio_only,excerpt: v.description ? clean(v.description, 220) : null,channel: person(owner), ai: false,};@@@ -150,6 +151,7 @@ async function clipItem(c, { ai }) {created_at: iso(c.created_at), views: num(c.view_count),duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),thumbnail_url: media.publicUrl(c.thumbnail_url) || null,++ audio_only: !!c.audio_only,preview_url: media.publicUrl(c.playback_url) || media.clipUrl(c.id),excerpt: summary ? clean(summary, 220) : (c.description ? clean(c.description, 220) : null),channel: person(channelOwner || creator), by, ai,@@@ -334,12 +334,19 @@ async function fireStreamLifecycle(kind, streamId) {try { await getDb().tx(() => streamLifecycleHook(kind, streamId)); } catch (err) { console.warn(`[Events] stream ${kind} event for ${streamId} not queued:`, err.message); }}--async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url }) {++async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels = null }) {return await getDb().tx(async () => {++ // The live session starts with the slot's creator labels; the AI's suggestions stay on the session.++ let labels = content_labels;++ if (!labels && managed_stream_id) {++ const slot = await get('SELECT content_labels FROM managed_streams WHERE id = ?', [managed_stream_id]);++ if (slot && slot.content_labels) labels = slot.content_labels;++ }++ const labelJson = typeof labels === 'string' ? labels : JSON.stringify(labels || { labels: [], suggested: [] });const result = await run(-- `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, is_live, started_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,-- [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null]++ `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels, is_live, started_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,++ [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null, labelJson]);await fireStreamLifecycle('started', result.lastInsertRowid);return result;@@@ -1043,11 +1050,11 @@ async function updateViewerCount(streamId, count) {// ── Managed Stream helpers ───────────────────────────────────--async function createManagedStream({ user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw }) {++async function createManagedStream({ user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw, content_labels = null }) {return await run(-- `INSERT INTO managed_streams (user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,-- [user_id, channel_id || null, slug || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', streaming_method || null, stream_key, is_nsfw ? 1 : 0]++ `INSERT INTO managed_streams (user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw, content_labels)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,++ [user_id, channel_id || null, slug || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', streaming_method || null, stream_key, is_nsfw ? 1 : 0, typeof content_labels === 'string' ? content_labels : JSON.stringify(content_labels || { labels: [], suggested: [] })]);}@@@ -1114,14 +1121,14 @@ async function updateManagedStream(managedStreamId, userId, fields) {'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled','slot_clip_notify_enabled', 'slot_powerchat_relay', 'slot_powerchat_count_rs_views','weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',-- 'pip_source_msid', 'pip_defaults',++ 'pip_source_msid', 'pip_defaults', 'content_labels',]);const updates = [];const params = [];for (const [key, val] of Object.entries(fields)) {if (val !== undefined && allowed.has(key)) {updates.push(`${key} = ?`);-- params.push(['tags', 'pip_defaults'].includes(key)++ params.push(['tags', 'pip_defaults', 'content_labels'].includes(key)? (typeof val === 'string' ? val : JSON.stringify(val)): val);}@@@ -1132,6 +1139,30 @@ async function updateManagedStream(managedStreamId, userId, fields) {return await run(`UPDATE managed_streams SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, params);}++// ── Content labels (migration 0023, L1 labels and choices) ───++// A creator's labels live on the slot and are copied to each live session. The AI's suggestions are stored on the++// session and never applied; a suggestion the creator confirms leaves the suggested list. None of this hides++// anything from anyone: a label is read by the page and the viewer's network.label-prefs@1 decides (except the home++// page, which skips 18+).++/** A slot's labels changed: the live session shows them at once, keeping the AI suggestions it already has. */++async function setLiveStreamsContentLabels(managedStreamId, labels) {++ return await run(`UPDATE streams SET content_labels = jsonb_set(content_labels, '{labels}', ?::jsonb) WHERE managed_stream_id = ? AND is_live = 1`, [JSON.stringify(labels || []), managedStreamId]);++}++++/** Add AI suggestions to a live session. Read-modify-write in a transaction so two frames never lose a suggestion. */++async function suggestStreamContentLabels(streamId, labels) {++ const shape = require('../labels');++ return await getDb().tx(async () => {++ const row = await get('SELECT content_labels FROM streams WHERE id = ? FOR UPDATE', [streamId]);++ if (!row) return null;++ const current = shape.labelsOf(row);++ const next = shape.shape(current.labels, [...current.suggested, ...(labels || [])]);++ if (next.suggested.length === current.suggested.length) return current;++ await run('UPDATE streams SET content_labels = ? WHERE id = ?', [JSON.stringify(next), streamId]);++ return next;++ });++}++/*** Resolve the picture-in-picture camera overlay for a slot.*@@@ -3654,6 +3685,7 @@ module.exports = {createManagedStream, getManagedStreamById, getManagedStreamsByUserId,getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,updateManagedStream, deleteManagedStream,++ setLiveStreamsContentLabels, suggestStreamContentLabels,getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, getLiveOwnPipSourceSlots, MAX_PIPS,countManagedStreamsByUser, getManagedStreamLimit,@@@ -477,6 +477,7 @@ router.get('/digest', optionalAuth, async (req, res) => {const followed = new Set(uid ? (await db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [uid]) || []).map(r => r.streamer_id) : []);const liveNow = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_atFROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0++ AND NOT (s.content_labels->'labels' @> '["18+"]'::jsonb)ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10`) || []).map(r => ({ ...r, followed: followed.has(r.user_id) }));const liveIds = new Set(liveNow.map(r => r.user_id));const streamedRows = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color,nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++/**++ * Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).++ *++ * The one label list, the shape a stream stores and returns, the creator's write, and the AI's *suggestion*.++ * A label is never a ban: it never hides content from the person who posted it, and it changes nothing until the++ * creator confirms it. The AI only ever writes to `suggested`; a suggestion never moves itself into `labels`.++ */++const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);++const SHAPE = Object.freeze({ labels: Object.freeze([]), suggested: Object.freeze([]) });++++const isLabel = (x) => typeof x === 'string' && LABELS.includes(x);++++/** A clean label list from a creator's request, or null when it is not a list of known labels. */++function parseLabels(value) {++ if (!Array.isArray(value)) return null;++ const out = [];++ for (const label of value) {++ if (!isLabel(label)) return null;++ if (!out.includes(label)) out.push(label);++ }++ return out;++}++++/** The contract's shape from a jsonb column, a JSON string, or nothing: always two arrays of known labels. */++function labelsOf(row) {++ let raw = row && typeof row === 'object' && 'content_labels' in row ? row.content_labels : row;++ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }++ const pick = (v) => [...new Set((Array.isArray(v) ? v : []).filter(isLabel))];++ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };++}++++/** The shape to store: `labels` are in force, `suggested` is still a suggestion. */++const shape = (labels, suggested = []) => ({ labels: [...new Set((labels || []).filter(isLabel))], suggested: [...new Set((suggested || []).filter(isLabel))] });++++/** Apply a creator's labels and drop the ones they just confirmed from the suggestions. */++function confirmed(current, labels) {++ const now = labelsOf(current);++ return { labels: [...labels], suggested: now.suggested.filter((l) => !labels.includes(l)) };++}++++// What the AI sees in the frame notes and hears in the transcript. Narrow on purpose: a label is a suggestion for++// the creator, not a verdict, and it never touches the content itself.++const NUDITY = /\b(?:nude|naked|nudity|topless|undressed|bare\s+(?:chest|breasts?|bottom)|explicit(?:ly)?\s+(?:adult|sexual)|sexual\s+(?:acts?|content|activity|material)|porn(?:o|ography|ographic)?|erotic\w*|genitals?|masturbat\w*|intercourse)\b/i;++const GRAPHIC = /\b(?:blood(?:y|ied)?|gore|gory|graphic\s+(?:violence|injury)|severe\s+injur\w*|dead\s+body|corpse|mutilat\w*|dismember\w*)\b/i;++const FLASHING = /\b(?:strobe[sd]?|strobing|flashing\s+lights?|seizure\s+warning|epilep\w*)\b/i;++const LANGUAGE = /\b(?:fuck\w*|shit(?:ty|s|ting)?|bitch\w*|cunts?|assholes?|bastards?|dick(?:s|head)?|puss(?:y|ies)|cock(?:s|sucker)?)\b/i;++++/**++ * What the AI suggests from one frame's notes and the transcript it heard: nudity from the scene (and 18+ with it),++ * graphic violence and flashing lights from the scene, strong language from what was said. Spoilers cannot be++ * guessed from a frame, so they are never suggested. Order follows the label list.++ */++function suggestFrom({ description, transcript } = {}) {++ const scene = String(description || '');++ const heard = String(transcript || '');++ const found = new Set();++ if (NUDITY.test(scene) || NUDITY.test(heard)) { found.add('nudity'); found.add('18+'); }++ if (LANGUAGE.test(heard)) found.add('language');++ if (GRAPHIC.test(scene)) found.add('graphic');++ if (FLASHING.test(scene)) found.add('flashing');++ return LABELS.filter((l) => found.has(l));++}++++module.exports = { LABELS, SHAPE, isLabel, parseLabels, labelsOf, shape, confirmed, suggestFrom };@@@ -12,6 +12,7 @@ const { requireAuth, optionalAuth } = require('../auth/auth');const permissions = require('../auth/permissions');const access = require('./access');const purge = require('./purge');++const contentLabels = require('../labels');const { commentCount } = require('./comments');const lineage = require('../lineage/resolver');const cache = require('openvibe-shared/cache-policy');@@@ -372,6 +373,23 @@ router.put('/:id/title', requireAuth, async (req, res) => {}});++// L1: the creator (or a channel moderator) labels the clip. Media stores `{ labels, suggested }`; a suggestion the++// creator confirms leaves the suggested list. A label never hides the clip from anyone by itself.++router.put('/:id/labels', requireAuth, async (req, res) => {++ try {++ let clip;++ try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }++ if (!clip) return res.status(404).json({ error: 'Clip not found' });++ if (!(await canActorModerateClip(req.user, clip))) return await refuse(req, res, clip, 'Not authorized to edit this clip');++ const nextLabels = contentLabels.parseLabels((req.body || {}).labels);++ if (nextLabels === null) return res.status(400).json({ error: 'labels must be a list of known labels' });++ const out = await media.updateClip(clip.id, { labels: nextLabels });++ res.json({ message: 'Clip labels updated', content_labels: (out && out.clip && out.clip.content_labels) || { labels: nextLabels, suggested: [] } });++ } catch (err) {++ mediaErr(res, err, 'Failed to update clip labels');++ }++});++router.put('/:id/visibility', requireAuth, async (req, res) => {try {let clip;@@@ -27,6 +27,7 @@ const permissions = require('../auth/permissions');const access = require('./access');const purge = require('./purge');const { commentCount } = require('./comments');++const contentLabels = require('../labels');const cache = require('openvibe-shared/cache-policy');const router = express.Router();@@@ -652,11 +653,16 @@ router.put('/:id', requireAuth, async (req, res) => {if (!vod) return;try {const { title, description, is_public, visibility } = req.body || {};++ // L1: a creator labels a VOD here (MediaHub's manage form and the SPA both call this route), and a++ // suggestion the creator confirms leaves `suggested`. Media stores the shape; Live forwards it.++ const nextLabels = req.body && req.body.labels !== undefined ? contentLabels.parseLabels(req.body.labels) : undefined;++ if (nextLabels === null) return res.status(400).json({ error: 'labels must be a list of known labels' });const fields = {};if (title !== undefined) fields.title = title;if (description !== undefined) fields.description = description;if (visibility !== undefined) fields.visibility = visibility;else if (is_public !== undefined) fields.visibility = is_public ? 'public' : 'private';++ if (nextLabels !== undefined) fields.labels = nextLabels;await media.updateVod(req.params.id, fields);res.json({ vod: await media.getVod(req.params.id).catch(() => ({ ...vod, ...fields })) });} catch (err) {@@@ -505,9 +505,10 @@ async function _homeMeta() {const title = 'OpenVibe.Live — Go Live From Your Browser, No OBS Needed';const description = 'Go live instantly from your browser: no OBS, no downloads, no follower minimum. Open-source, community-run streaming with sub-second WebRTC, chat, VODs, clips and restreaming to Twitch, YouTube and Kick.';-- // Pull the actual live content so the source has real, crawlable text.++ // Pull the actual live content so the source has real, crawlable text. The home page is the owner's clean++ // page (L1): a stream labelled 18+ is never listed here, whatever a viewer's prefs say.let live = [];-- try { live = (await db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }++ try { live = (await db.getLiveStreams() || []).filter((s) => !(require('../labels').labelsOf(s).labels.includes('18+'))).slice(0, 12); } catch { /* */ }// Each rail waits at most LIST_WAIT_MS: a slow upstream leaves its rail out, never the page.// VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.let homeStats = null;@@@ -24,6 +24,7 @@*/const express = require('express');const db = require('../db/database');++const contentLabels = require('../labels');const { can } = require('../auth/permissions');const config = require('../config');const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');@@@ -1182,7 +1183,10 @@ router.get('/', optionalAuth, async (req, res) => {const restreamManager = require('./restream-manager');// A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.const insidePip = await db.getLiveOwnPipSourceSlots();-- let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));++ // The home page is the owner's one clean page (L1): nothing labelled 18+ is listed there, whatever a++ // viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.++ let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id)))++ && !contentLabels.labelsOf(s).labels.includes('18+'));// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.// A stream matches on its own tags or the AI's, without carrying either into the response.if (wantedTags.length) {@@@ -1672,6 +1676,8 @@ router.post('/managed', requireAuth, async (req, res) => {const category = cleanText(req.body.category, { maxLength: MAX_CATEGORY_LENGTH }) || null; // empty = let the AI infer it from the streamconst protocol = cleanProtocol(req.body.protocol) || 'webrtc';const is_nsfw = cleanBooleanFlag(req.body.is_nsfw);++ const labels = hasOwn(req.body, 'labels') ? contentLabels.parseLabels(req.body.labels) : [];++ if (labels === null) return res.status(400).json({ error: 'Invalid labels' });let slug = req.body.slug ? req.body.slug.trim().toLowerCase() : null;if (slug) {@@@ -1700,6 +1706,7 @@ router.post('/managed', requireAuth, async (req, res) => {streaming_method: cleanText(req.body.streaming_method, { maxLength: 20 }) || null,stream_key,is_nsfw,++ content_labels: contentLabels.shape(labels),});const managedStream = await db.getManagedStreamById(result.lastInsertRowid);@@@ -1748,6 +1755,13 @@ router.put('/managed/:id', requireAuth, async (req, res) => {fields.tags = cleanTags(req.body.tags);if (fields.tags === null) return res.status(400).json({ error: 'Invalid tags' });}++ if (hasOwn(req.body, 'labels')) {++ // The creator's labels (L1): applied to the slot and its live session; a confirmed suggestion leaves++ // the suggested list. Labels never hide anything here; the viewer's Network prefs decide.++ const labels = contentLabels.parseLabels(req.body.labels);++ if (labels === null) return res.status(400).json({ error: 'Invalid labels' });++ fields.content_labels = contentLabels.confirmed(ms.content_labels, labels);++ }if (hasOwn(req.body, 'pip_source_msid')) {// null / '' clears the overlay. Anything else must be one of this user's own// slots and must not be this slot, which would ask the player to render a@@@ -1855,6 +1869,7 @@ router.put('/managed/:id', requireAuth, async (req, res) => {await db.setPipsForManagedStream(msId, fields.pip_source_msid ? [{ source_msid: fields.pip_source_msid, ...d }] : []);}const updated = await db.getManagedStreamById(msId);++ if (fields.content_labels) await db.setLiveStreamsContentLabels(msId, contentLabels.labelsOf(updated).labels);// If the title changed and this slot has a RobotStreamer integration, mirror the// new title to the RS robot name (best-effort, non-blocking).@@@ -2232,11 +2247,15 @@ router.put('/:id', requireAuth, async (req, res) => {const description = cleanText(req.body.description, { maxLength: MAX_DESCRIPTION_LENGTH, allowEmpty: true });const category = cleanText(req.body.category, { maxLength: MAX_CATEGORY_LENGTH });const tags = cleanTags(req.body.tags);++ // The creator confirms or clears labels on the live session too (L1): this applies them and drops the++ // confirmed ones from the AI's suggestions. A label never hides the stream from its own creator.++ const labels = hasOwn(req.body, 'labels') ? contentLabels.parseLabels(req.body.labels) : undefined;if ((hasOwn(req.body, 'title') && title === null)|| (hasOwn(req.body, 'description') && description === null)|| (hasOwn(req.body, 'category') && category === null)-- || (hasOwn(req.body, 'tags') && tags === null)) {++ || (hasOwn(req.body, 'tags') && tags === null)++ || labels === null) {return res.status(400).json({ error: 'Invalid stream update' });}@@@ -2256,6 +2275,7 @@ router.put('/:id', requireAuth, async (req, res) => {}}if (tags !== undefined) { updates.push('tags = ?'); params.push(JSON.stringify(tags)); }++ if (labels !== undefined) { updates.push('content_labels = ?'); params.push(JSON.stringify(contentLabels.confirmed(stream.content_labels, labels))); }if (updates.length > 0) {params.push(req.params.id);@@@ -75,7 +75,7 @@ const FEED_ITEM_FIELDS = {views: 'number', likes: 'number', duration_seconds: 'number',thumbnail_url: 'string', preview_url: 'string', image_url: 'string',excerpt: 'string', paste_type: 'string', language: 'string', nsfw: 'boolean',-- ai: 'boolean', ai_label: 'string', moment_href: 'string', grade: 'string', stream_title: 'string',++ ai: 'boolean', ai_label: 'string', moment_href: 'string', grade: 'string', stream_title: 'string', audio_only: 'boolean',};const FEED_PERSON_FIELDS = ['username', 'display_name', 'avatar_url', 'profile_color', 'href'];nnew file mode 100644@@@ -0,0 +1,30 @@++'use strict';++// Card chips for an audio-only recording or clip (Media#240/R4): the picture is Media's avatar card, and the card says++// "Audio". The chip is drawn only when the item says audio_only, so every other card is unchanged.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');++const between = (startMark, endMark) => { const a = js.indexOf(startMark); const b = js.indexOf(endMark, a); assert.ok(a >= 0 && b > a, startMark); return js.slice(a, b); };++const ctx = vm.createContext({});++vm.runInContext(`${between('function audioChip(item) {', '\nfunction formatDuration(')}\nthis.audioChip = audioChip;`, ctx);++++const chip = ctx.audioChip({ audio_only: true });++assert.ok(chip.includes('class="stream-card-audio"') && chip.includes('Audio'), chip);++assert.ok(chip.includes('fa-microphone'), 'a microphone says why there is no picture');++assert.strictEqual(ctx.audioChip({ audio_only: 1 }), chip, 'Media may hand the boolean back as 1');++assert.strictEqual(ctx.audioChip({ audio_only: false }), '');++assert.strictEqual(ctx.audioChip({}), '');++assert.strictEqual(ctx.audioChip(null), '');++++// The channel's VOD list and the home rails use it; the stylesheet places it on the picture.++const channel = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');++const home = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');++assert.match(channel, /audioChip\(v\)/, 'the channel VOD list');++assert.match(home, /audioChip\(v\)/, 'the home recordings rail');++assert.match(home, /audioChip\(c\)/, 'the home clips rail');++assert.match(css, /\.stream-card-audio \{[^}]*position: absolute;[^}]*top: 8px; left: 8px;/);++console.log('audio-only chip: all passed');@@@ -48,7 +48,7 @@ let VODS = [], CLIPS = [];const vod = (id, mins, extra = {}) => ({ id, user_id: 3, title: `VOD ${id}`, visibility: 'public', is_public: true, status: 'ready', duration_seconds: 600, view_count: 0, created_at: at(mins), thumbnail_url: `/t/vod-${id}.jpg`, file_path: `vod-${id}.webm`, storage_key: 'secret/key', ...extra });const clip = (id, mins, extra = {}) => ({ id, user_id: 4, channel_user_id: 3, vod_id: 1, title: `Clip ${id}`, visibility: 'public', is_public: true, status: 'ready', auto_generated: false, duration_seconds: 20, view_count: 0, created_at: at(mins), thumbnail_url: `/t/clip-${id}.jpg`, playback_url: `https://media.test/c/${id}`, file_path: `clip-${id}.webm`, ...extra });VODS = [-- vod(1, 10, { view_count: 50 }), vod(2, 30, { view_count: 5 }), vod(3, 50, { view_count: 500 }), vod(4, 70),++ vod(1, 10, { view_count: 50 }), vod(2, 30, { view_count: 5, audio_only: true }), vod(3, 50, { view_count: 500 }), vod(4, 70),vod(5, 90, { visibility: 'private', is_public: false, title: 'SECRET VOD' }),vod(6, 95, { visibility: 'unlisted', is_public: false, title: 'UNLISTED VOD' }),vod(7, 100, { user_id: 9, title: 'Banned streamer VOD' }),@@@ -204,10 +204,12 @@ server.on('listening', async () => {assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && c.act.liveUserId == null), 'Community is read as nobody in particular');const r = await get('/api/content/feed?limit=30');assert.deepStrictEqual(findSecrets(r.json), []);-- const allowed = new Set(['key', 'kind', 'id', 'href', 'title', 'created_at', 'views', 'likes', 'duration_seconds', 'thumbnail_url', 'preview_url', 'image_url', 'excerpt', 'paste_type', 'language', 'nsfw', 'ai', 'ai_label', 'moment_href', 'grade', 'stream_title', 'channel', 'by']);++ const allowed = new Set(['key', 'kind', 'id', 'href', 'title', 'created_at', 'views', 'likes', 'duration_seconds', 'thumbnail_url', 'preview_url', 'image_url', 'excerpt', 'paste_type', 'language', 'nsfw', 'ai', 'ai_label', 'moment_href', 'grade', 'stream_title', 'audio_only', 'channel', 'by']);for (const it of r.json.items) for (const k of Object.keys(it)) assert.ok(allowed.has(k), `unexpected field ${k} on ${it.key}`);const v1 = r.json.items.find((i) => i.key === 'vod:1');assert.strictEqual(v1.thumbnail_url, `${media.MEDIA_PUBLIC_URL}/t/vod-1.jpg`, 'Media paths become absolute');++ assert.strictEqual(v1.audio_only, false, 'a picture recording says so');++ assert.strictEqual((await get('/api/content/feed?type=vods&limit=30')).json.items.find((i) => i.key === 'vod:2').audio_only, true, 'an audio-only recording carries the flag to the card');assert.deepStrictEqual(v1.channel, { username: 'alice', display_name: 'ALICE', avatar_url: 'https://media.test/a/alice.png', profile_color: '#8b5cf6', href: '/@alice' });assert.ok(!('file_path' in v1) && !('storage_key' in v1));const c11 = r.json.items.find((i) => i.key === 'clip:11');nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++// Content labels (L1, 2026-10-31; Contracts 0.257.0 common.content-labels@1): a creator labels a stream slot, each++// live session copies them, the channel page and the watch screen return them, the home list skips 18+, and the AI's++// suggestion is stored in `suggested` and never applied until the creator confirms it.++// node test/content-labels.test.js++const assert = require('assert');++const http = require('http');++process.env.NODE_ENV = 'test';++console.log = () => {};++console.warn = () => {};++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const raw = db.getDb();++++ // Requests carry their user in x-test-user (the auth middleware is replaced before the routes load).++ const auth = require('../server/auth/auth');++ const signIn = async (req) => { const u = await db.getUserById(Number(req.headers['x-test-user'] || 0)); if (u) req.user = u; return u; };++ auth.requireAuth = async (req, res, next) => ((await signIn(req)) ? next() : res.status(401).json({ error: 'Authentication required' }));++ auth.optionalAuth = async (req, res, next) => { await signIn(req); next(); };++ require('../server/integrations/robotstreamer-service').startForStream = async () => {};++ require('../server/integrations/chat-relay-service').startForStream = async () => {};++ require('../server/streaming/live-events').announceGoLive = () => {};++++ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE VALUES (1, 'labels', 'Labels', 'l@x', 'x', 'streamer')`).run();++ const channel = await db.ensureChannel(1);++++ 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, '127.0.0.1');++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}/api/streams`;++ const call = (method, path, body) => fetch(base + path, {++ method, headers: { 'content-type': 'application/json', 'x-test-user': '1' }, body: body ? JSON.stringify(body) : undefined,++ }).then(async (r) => ({ status: r.status, body: await r.json() }));++ const makeSlot = (title, labels) => db.createManagedStream({++ user_id: 1, channel_id: channel.id, title, protocol: 'webrtc',++ stream_key: `${title}`.repeat(32).slice(0, 32), content_labels: { labels, suggested: [] },++ }).then((r) => Number(r.lastInsertRowid || r.id));++ const labelsOf = (body) => require('../server/labels').labelsOf(body);++++ const { suggestFrom } = require('../server/labels');++ let pass = 0;++ const ok = () => { pass++; };++++ try {++ // The AI's suggestion: nudity and 18+ from the scene, language from the transcript; spoilers are never guessed.++ assert.deepStrictEqual(suggestFrom({ description: 'The streamer appears naked in the room', transcript: 'that was fucking wild' }), ['nudity', 'language', '18+']);++ assert.deepStrictEqual(suggestFrom({ description: 'Blood covers the floor after a graphic fight' }), ['graphic']);++ assert.deepStrictEqual(suggestFrom({ description: 'A strobing light fills the stage' }), ['flashing']);++ assert.deepStrictEqual(suggestFrom({ description: 'A player wins a round in Fortnite', transcript: 'nice shot' }), []);++ ok('the suggester reads the scene and the transcript, never a spoiler');++++ // Two slots: one plain, one labelled 18+. Their live sessions carry the labels.++ const plain = await makeSlot('plain', ['language']);++ const adult = await makeSlot('adult', ['18+']);++ const plainLive = await call('POST', '', { managed_stream_id: plain, title: 'A quiet stream' });++ const adultLive = await call('POST', '', { managed_stream_id: adult, title: 'An adult stream' });++ assert.strictEqual(plainLive.status, 201, JSON.stringify(plainLive.body));++ assert.strictEqual(adultLive.status, 201, JSON.stringify(adultLive.body));++ assert.deepStrictEqual(labelsOf(plainLive.body.stream).labels, ['language'], 'a live session copies the slot labels');++ assert.deepStrictEqual(labelsOf(adultLive.body.stream).labels, ['18+']);++++ // The home list (GET /api/streams, what the home live rail polls) skips 18+; the channel page does not.++ const home = await call('GET', '');++ const homeIds = home.body.streams.map((s) => Number(s.id));++ assert.ok(homeIds.includes(Number(plainLive.body.stream.id)), 'the plain stream is on the home list');++ assert.ok(!homeIds.includes(Number(adultLive.body.stream.id)), 'nothing labelled 18+ is on the home list');++ assert.ok(!JSON.stringify(home.body).includes('An adult stream'), 'the 18+ stream is not in the home payload at all');++ const onChannel = await call('GET', '/channel/labels/live');++ const shown = onChannel.body.streams.find((s) => Number(s.id) === Number(adultLive.body.stream.id));++ assert.ok(shown, 'the channel page still shows it');++ assert.deepStrictEqual(labelsOf(shown).labels, ['18+'], 'with its chip');++ ok('the home page skips 18+; the channel page shows it with its label');++++ // The AI suggests on the plain stream: stored as suggested, never applied.++ const streamId = Number(plainLive.body.stream.id);++ const suggested = await db.suggestStreamContentLabels(streamId, ['nudity', '18+']);++ assert.deepStrictEqual(suggested.suggested, ['nudity', '18+']);++ let live = await call('GET', `/${streamId}`);++ assert.deepStrictEqual(labelsOf(live.body.stream), { labels: ['language'], suggested: ['nudity', '18+'] }, 'a suggestion changes no label');++ ok('the AI suggestion is stored as suggested, never applied');++++ // The creator confirms: the labels apply, the confirmed ones leave the suggestions.++ let r = await call('PUT', `/${streamId}`, { labels: ['language', 'nudity', '18+'] });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(labelsOf(r.body.stream), { labels: ['language', 'nudity', '18+'], suggested: [] }, 'confirming applies and clears the suggestion');++ r = await call('GET', '');++ assert.ok(!r.body.streams.some((s) => Number(s.id) === streamId), 'a newly labelled 18+ stream leaves the home list');++ assert.strictEqual((await call('PUT', `/${streamId}`, { labels: ['gore'] })).status, 400, 'an unknown label is refused');++ ok('the creator confirms; an unknown label is refused');++++ // The setup/dashboard writes the slot: the live session follows, and its suggestions are kept.++ await db.suggestStreamContentLabels(streamId, ['flashing']);++ r = await call('PUT', `/managed/${plain}`, { labels: ['language', 'spoilers'] });++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(labelsOf(r.body.managed_stream), { labels: ['language', 'spoilers'], suggested: [] }, 'the slot stores the creator labels');++ live = await call('GET', `/${streamId}`);++ assert.deepStrictEqual(labelsOf(live.body.stream), { labels: ['language', 'spoilers'], suggested: ['flashing'] }, 'the live session takes the new labels and keeps the AI suggestion');++ assert.strictEqual((await call('PUT', `/managed/${plain}`, { labels: ['gore'] })).status, 400, 'the setup refuses an unknown label');++ ok('the setup writes the slot and the live session follows');++++ // A signed-out viewer sees the same labels: nothing is hidden from anyone by the label itself.++ const anon = await fetch(`http://127.0.0.1:${server.address().port}/api/streams/channel/labels/live`).then((x) => x.json());++ assert.deepStrictEqual(labelsOf(anon.streams.find((s) => Number(s.id) === streamId)).labels, ['language', 'spoilers'], 'a signed-out visitor gets the chip data');++ ok('a signed-out visitor sees the labels');++ } finally {++ await new Promise((r) => server.close(r));++ }++ console.log(`content labels: ${pass} checks — home skips 18+, suggestions stay suggestions`);++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++++/**++ * Labels on the go-live card (L1, with Live #477's API): a chip in the row and a fold with the six labels as toggles;++ * the AI's suggestions are offered to confirm, never applied by themselves; the save sends the creator's labels.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ws = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-workspace.js'), 'utf8');++const panel = ws.slice(ws.indexOf('function _wsRenderPanel()'), ws.indexOf('\n}\n', ws.indexOf('function _wsRenderPanel()')));++const fn = (name) => { const i = ws.indexOf(`function ${name}(`); assert.ok(i > 0, name); return ws.slice(i, ws.indexOf('\n}\n', i)); };++++// The chip and the fold.++assert.ok(panel.includes(`onclick="_glOpenFold('gl-labels')"`) && panel.includes('id="gl-labels-v"'), 'a Labels chip that opens its fold');++assert.ok(panel.includes('<details class="gl-fold" id="gl-labels">') && panel.includes('id="gl-labels-sum"'), 'a Labels fold whose line says what is set');++assert.ok(panel.includes('Labels are choices, not bans.'), 'it says what a label does, once');++++// The six labels, in the contract's words; 18+ in the warning colour.++const L = ws.slice(ws.indexOf('const _GL_LABELS = '), ws.indexOf('\n', ws.indexOf('const _GL_LABELS = ')));++for (const id of ['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']) assert.ok(L.includes(`['${id}',`), id);++assert.ok(fn('_glLabelsChips').includes("aria-pressed=") && fn('_glLabelsChips').includes('gl-label--adult'), 'toggles that say their state');++++// The AI only suggests: its chips add a label when tapped, and nothing else moves a suggestion into the labels.++const sug = fn('_glLabelsSuggested');++assert.ok(sug.includes('Nothing changes until you add one.'), 'a suggestion says it changes nothing');++assert.ok(sug.includes("onclick=\"_glToggleLabel('${id}')\""), 'a tap adds it');++const toggle = fn('_glToggleLabel');++assert.ok(toggle.includes('_wsMarkDirty()'), 'a change saves with the card');++assert.ok(toggle.includes('suggested: cur.suggested.filter((l) => !labels.includes(l))'), 'a confirmed suggestion leaves the suggestions');++++// The save sends the creator's labels (PUT /api/streams/managed/:id { labels }).++const save = fn('_wsSaveAll');++assert.ok(save.includes('labels: _glLabelsOf(ms).labels,'), 'the save sends the labels');++assert.ok(fn('_glSyncSummaries').includes("set('gl-labels-sum'"), 'the line follows the change');++++console.log('golive-labels: all passed');nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++// Content labels on Live's cards and player (L1): each carries data-ov-labels for Shared's ov-labels.js (the viewer's++// own show, blur or hide) and chips in words; the streamer's own content is never marked for them.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');++const app = read('public/js/app.js');++const start = app.indexOf('const OV_LABEL_NAMES = ');++const ctx = { currentUser: null, window: {}, esc: (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) };++vm.createContext(ctx);++vm.runInContext(app.slice(start) + '\n;this.h = { ovLabelsOf, ovLabelsAttr, ovLabelChips, ovIsMine };', ctx);++const { ovLabelsOf, ovLabelsAttr, ovLabelChips } = ctx.h;++++const stream = { username: 'carol', content_labels: { labels: ['language', '18+', 'bogus'], suggested: ['nudity'] } };++assert.deepStrictEqual([...ovLabelsOf(stream)], ['language', '18+'], 'only the labels in force, only known ones');++assert.strictEqual(ovLabelsAttr(stream), ' data-ov-labels="language,18+"');++assert.ok(ovLabelChips(stream).includes('<li class="ovk-label" data-label="language">Strong language</li>'), 'chips in words');++assert.ok(ovLabelChips(stream).includes('ovk-label--adult'), '18+ in the warning colour');++assert.strictEqual(ovLabelChips({ content_labels: { labels: [], suggested: ['nudity'] } }), '', 'a suggestion is not a label');++assert.strictEqual(ovLabelsAttr({ username: 'x', content_labels: '{"labels":["graphic"],"suggested":[]}' }), ' data-ov-labels="graphic"', 'a JSON string is read too');++ctx.currentUser = { username: 'Carol' };++assert.strictEqual(ovLabelsAttr(stream), '', 'the streamer\'s own stream is never marked for them');++++const home = read('public/js/app-home.js');++assert.ok(home.includes('<a class="stream-card"${ovLabelsAttr(s)}'), 'the home card is marked');++assert.ok(home.includes('style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>'), 'its picture is what a blur covers');++assert.ok(home.includes('${ovLabelChips(s)}'), 'its chips under the title');++const player = read('public/js/stream-player.js');++assert.ok(player.includes("ovMarkLabels(document.getElementById('video-container'), stream, { opened: true });"), 'the channel player is marked when a stream plays, as its own page (hide covers, never empties it)');++assert.ok(/el\.toggleAttribute\('data-ov-labels-opened', Boolean\(opened && labels\.length\)\)/.test(app), 'opened is set only on a labelled thing');++assert.ok(read('public/fragments/channel.html').includes('<div id="stream-labels" class="stream-labels"></div>'), 'the channel shows the chips under the title');++console.log('labels-view: all passed');@@@ -16,7 +16,7 @@ assert.match(_thumbTint({ profile_color: 'red;background:url(x)', username: 'anaassert.strictEqual(_thumbTint({ username: 'ana' }), _thumbTint({ username: 'ana' }), 'the same name, the same colour');assert.notStrictEqual(_thumbTint({ username: 'ana' }), _thumbTint({ username: 'kai' }), 'another name, another colour');assert.match(_thumbTint({}), /^hsl\(0 45% 34%\)$/);--assert.match(src, /<div class="stream-card-thumb\$\{[^}]+\}" style="--thumb-tint:\$\{esc\(_thumbTint\(s\)\)\}">/, 'the card wears it');++assert.match(src, /<div class="stream-card-thumb\$\{[^}]+\}" style="--thumb-tint:\$\{esc\(_thumbTint\(s\)\)\}"[^>]*>/, 'the card wears it');const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');assert.match(css, /\.stream-card-thumb \{\s*aspect-ratio: 16\/9;[\s\S]{0,200}var\(--thumb-tint/, 'the box is drawn in it');console.log('thumb tint: all checks passed');
@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -177,5 +177,5 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.222.0
- openvibe-contracts: v0.222.0
- openvibe-sdk: v0.74.0
- openvibe-sdk: v0.74.0
-- openvibe-shared: v4.92.0
+- openvibe-shared: v4.94.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
"contracts": "openvibe-contracts v0.222.0",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.74.0",
"sdk": "openvibe-sdk v0.74.0",
- "shared": "openvibe-shared v4.92.0",
+ "shared": "openvibe-shared v4.94.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+-- phase: expand
+-- Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
+--
+-- A creator labels a stream slot (managed_streams) in the setup/dashboard; the live session copies those labels when
+-- it starts, so the channel page, the cards and the watch screen read them from the stream itself. The AI may
+-- SUGGEST a label from the frame notes it already writes; a suggestion is stored in `suggested` and changes nothing
+-- for anyone until the creator confirms it. Three viewer suggestions or a moderator may put a label on later.
+--
+-- Labels are choices, not bans: nothing here hides content from the person who posted it, and nothing filters a
+-- viewer's search or feed except the one clean page (the home page skips 18+). The query that reads a label uses the
+-- jsonb containment operator, so the GIN index answers it.
+--
+-- Expand only: an older release never reads these columns, and the defaults keep every existing row label-free.
+ALTER TABLE managed_streams ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;
+ALTER TABLE streams ADD COLUMN IF NOT EXISTS content_labels jsonb NOT NULL DEFAULT '{"labels": [], "suggested": []}'::jsonb;
+CREATE INDEX IF NOT EXISTS streams_content_labels ON streams USING gin (content_labels);
+CREATE INDEX IF NOT EXISTS managed_streams_content_labels ON managed_streams USING gin (content_labels);
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.92.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2884,9 +2884,9 @@
@@ -2884,9 +2884,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.92.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.92.0",
- "integrity": "sha512-Z20rSXMAKef0d4VfqsFkJ76u4uIS8TiCZ5/Dqmpp00keYoGKz33qzykjpWraUuShmRQJdXzlGtmcAHdiWCxvOA==",
+ "version": "4.94.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
+ "integrity": "sha512-iMsgL7a5vxouygXsWxQACJrnRubhpe6tnMAKjiJZ0m6Fn+hFcJjQzrI/fERZB3ToC+xK4zO6b6hsm4zK6x4MYg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -44,7 +44,7 @@
@@ -44,7 +44,7 @@
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.92.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.94.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -3327,3 +3327,20 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
@@ -3327,3 +3327,20 @@ body.bc-golive-dock-on #page-broadcast { padding-bottom: calc(104px + var(--ovna
.gl-test-done:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.gl-test-done:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
@media (pointer: coarse) { .gl-test-done { min-height: 44px; } }
@media (pointer: coarse) { .gl-test-done { min-height: 44px; } }
@media (prefers-reduced-motion: reduce) { .gl-test-btn.is-rec i { animation: none; } }
@media (prefers-reduced-motion: reduce) { .gl-test-btn.is-rec i { animation: none; } }
+
+/* Labels (L1) in the go-live card's fold: six toggles; the AI's suggestions dashed, to confirm. */
+.gl-labels-why { margin: 0 0 10px; font-size: .82rem; line-height: 1.45; color: var(--text-secondary); }
+.gl-labels, .gl-labels-ai { display: flex; flex-wrap: wrap; gap: 6px; }
+.gl-labels-ai { margin-top: 12px; }
+.gl-labels-ai:empty { display: none; }
+.gl-labels-ai-h { flex-basis: 100%; margin: 0 0 2px; font-size: .78rem; color: var(--text-muted); }
+.gl-label { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; min-height: 36px; padding: 0 14px; border-radius: 999px; cursor: pointer;
+ font-size: .84rem; font-weight: 650; color: var(--text-secondary); border: 1px solid var(--border); background: color-mix(in srgb, var(--text-primary) 4%, transparent);
+ transition: background-color var(--ov-dur-2, .15s), border-color var(--ov-dur-2, .15s), color var(--ov-dur-2, .15s); }
+.gl-label:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 40%, var(--border)); }
+.gl-label:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.gl-label.is-on { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); background: color-mix(in srgb, var(--accent) 16%, transparent); }
+.gl-label--adult.is-on { color: var(--warning-text, var(--warning)); border-color: color-mix(in srgb, var(--warning-text, var(--warning)) 55%, var(--border)); background: color-mix(in srgb, var(--warning-text, var(--warning)) 10%, transparent); }
+.gl-label--ai { border-style: dashed; color: var(--accent); }
+@media (pointer: coarse) { .gl-label { min-height: 44px; } }
+@media (prefers-reduced-motion: reduce) { .gl-label { transition: none; } }
@@ -901,6 +901,20 @@ body.ov-nav-overlay .navbar.ovnav-top {
@@ -901,6 +901,20 @@ body.ov-nav-overlay .navbar.ovnav-top {
font-size: 0.65rem; font-weight: 700;
font-size: 0.65rem; font-weight: 700;
}
}
+/* Audio-only (Media#240/R4): the picture is Media's avatar card and the card says why there is no frame. */
+.stream-card-audio {
+ position: absolute; top: 8px; left: 8px;
+ display: inline-flex; align-items: center; gap: 4px;
+ padding: 2px 7px; border-radius: 999px;
+ background: color-mix(in srgb, var(--accent) 22%, rgba(9, 9, 13, 0.72));
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ color: #fff; font-size: 0.65rem; font-weight: 700; letter-spacing: 0.01em;
+ backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
+ pointer-events: none;
+}
+.stream-card-audio i { font-size: 0.78em; opacity: 0.95; }
+@container (max-width: 150px) { .stream-card-audio { top: 4px; left: 4px; padding: 1px 5px; font-size: 0.6rem; } }
+
/* Protocol badges */
/* Protocol badges */
.protocol-badge {
.protocol-badge {
display: inline-block;
display: inline-block;
@@ -10002,3 +10016,10 @@ body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transf
@@ -10002,3 +10016,10 @@ body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transf
html.ov-has-appbar .messenger-toggle { bottom: calc(var(--ovnav-appbar-h, 58px) + 14px); }
html.ov-has-appbar .messenger-toggle { bottom: calc(var(--ovnav-appbar-h, 58px) + 14px); }
html.ov-has-appbar body { padding-bottom: calc(var(--ovnav-appbar-h, 58px) + 64px); }
html.ov-has-appbar body { padding-bottom: calc(var(--ovnav-appbar-h, 58px) + 64px); }
}
}
+
+/* Content labels (L1): quiet chips in words, the adult one in the warning colour (as Shared's kit.labels). */
+.ovk-labels { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 0; padding: 0; list-style: none; }
+.ovk-label { display: inline-flex; align-items: center; min-height: 20px; padding: 0 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; letter-spacing: .02em;
+ color: var(--text-secondary); background: color-mix(in srgb, var(--text-primary) 7%, transparent); border: 1px solid var(--border); }
+.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
+.stream-labels:empty { display: none; }
@@ -121,6 +121,7 @@
@@ -121,6 +121,7 @@
<span id="ch-protocol-badge"></span>
<span id="ch-protocol-badge"></span>
</div>
</div>
<p class="ch-stream-title" id="ch-stream-title">Stream Title</p>
<p class="ch-stream-title" id="ch-stream-title">Stream Title</p>
+ <div id="stream-labels" class="stream-labels"></div>
<p class="ch-stream-description muted" id="ch-stream-description" style="display:none"></p>
<p class="ch-stream-description muted" id="ch-stream-description" style="display:none"></p>
<div class="ch-stream-ai" id="ch-stream-ai" style="display:none"></div>
<div class="ch-stream-ai" id="ch-stream-ai" style="display:none"></div>
<div class="ch-cumulative-viewers" id="ch-cumulative-viewers" style="display:none"></div>
<div class="ch-cumulative-viewers" id="ch-cumulative-viewers" style="display:none"></div>
@@ -167,6 +167,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -167,6 +167,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
<a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')" style="border:2px solid var(--accent);position:relative">
<a class="stream-card" href="${videoHref('vod', v.id)}" onclick="return handleLinkClick(event, '${videoHref('vod', v.id)}')" style="border:2px solid var(--accent);position:relative">
<div class="stream-card-thumb">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
+ ${audioChip(v)}
<span class="stream-card-nsfw" style="background:#e53e3e;animation:pulse 2s infinite">● RECORDING</span>
<span class="stream-card-nsfw" style="background:#e53e3e;animation:pulse 2s infinite">● RECORDING</span>
<span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || 0)}</span>
<span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || 0)}</span>
</div>
</div>
@@ -188,6 +189,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
@@ -188,6 +189,7 @@ async function renderChannelVodsSection(username, liveStreams, vods, meta = {})
<div class="stream-card-thumb">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${_visBadge(v.visibility, v.is_public, canManage)}
${_visBadge(v.visibility, v.is_public, canManage)}
+ ${audioChip(v)}
${v.stream_protocol ? protocolBadge(v.stream_protocol) : ''}
${v.stream_protocol ? protocolBadge(v.stream_protocol) : ''}
<span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || v.duration)}</span>
<span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || v.duration)}</span>
</div>
</div>
@@ -1646,6 +1646,7 @@ async function loadHomeRecentVods(opts) {
@@ -1646,6 +1646,7 @@ async function loadHomeRecentVods(opts) {
<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
<a class="stream-card" href="${href}" onclick="return handleLinkClick(event, '${href}')">
<div class="stream-card-thumb">
<div class="stream-card-thumb">
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
+ ${audioChip(v)}
${v.duration_seconds ? `<span class="stream-card-duration">${formatDuration(v.duration_seconds)}</span>` : ''}
${v.duration_seconds ? `<span class="stream-card-duration">${formatDuration(v.duration_seconds)}</span>` : ''}
${ovViewsBadge(v.view_count)}
${ovViewsBadge(v.view_count)}
</div>
</div>
@@ -1683,6 +1684,7 @@ async function loadHomeClips(opts) {
@@ -1683,6 +1684,7 @@ async function loadHomeClips(opts) {
<a class="stream-card" href="${videoHref('clip', c.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', c.id)}')">
<a class="stream-card" href="${videoHref('clip', c.id)}" onclick="return handleLinkClick(event, '${videoHref('clip', c.id)}')">
<div class="stream-card-thumb">
<div class="stream-card-thumb">
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
${thumbImg(c.thumbnail_url, 'fa-scissors', c.title, `/api/thumbnails/generate/clip/${c.id}`)}
+ ${audioChip(c)}
${ovViewsBadge(c.view_count)}
${ovViewsBadge(c.view_count)}
${c.duration_seconds ? `<span class="stream-card-duration">${formatDuration(c.duration_seconds)}</span>` : ''}
${c.duration_seconds ? `<span class="stream-card-duration">${formatDuration(c.duration_seconds)}</span>` : ''}
</div>
</div>
@@ -1885,8 +1887,8 @@ function streamCardHTML(s, isLive) {
@@ -1885,8 +1887,8 @@ function streamCardHTML(s, isLive) {
const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
const cat = String(s.category || '').replace(/[-_]+/g, ' ').trim().toLowerCase();
const shown = topics.filter((t) => t !== cat).slice(0, 2);
const shown = topics.filter((t) => t !== cat).slice(0, 2);
return `
return `
- <a class="stream-card"${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
- <div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}">
+ <a class="stream-card"${ovLabelsAttr(s)}${isLive ? ` data-topics="${esc(topics.join('|'))}" data-user="${esc(String(s.user_id || ''))}"` : ''} data-stream-id="${esc(String(s.id || ''))}" href="${esc(navUrl)}" onclick="return handleLinkClick(event, '${esc(navUrl)}')">
+ <div class="stream-card-thumb${s.is_nsfw ? ' stream-card-nsfw-blur' : ''}" style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
${thumbImg(thumb, 'fa-circle-nodes', s.title, !isLive && s.vod_id ? `/api/thumbnails/generate/vod/${s.vod_id}` : null)}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
${streamTypeBadge(s.browser_mode, s.streaming_method)}
${s.is_nsfw ? '<span class="stream-card-nsfw">18+</span>' : ''}
${s.is_nsfw ? '<span class="stream-card-nsfw">18+</span>' : ''}
@@ -1894,6 +1896,7 @@ function streamCardHTML(s, isLive) {
@@ -1894,6 +1896,7 @@ function streamCardHTML(s, isLive) {
</div>
</div>
<div class="stream-card-info">
<div class="stream-card-info">
<div class="stream-card-title">${esc(s.title || 'Untitled Stream')}</div>
<div class="stream-card-title">${esc(s.title || 'Untitled Stream')}</div>
+ ${ovLabelChips(s)}
<div class="stream-card-streamer">
<div class="stream-card-streamer">
${_avatarSpan(s.avatar_url, s.username, s.profile_color)}
${_avatarSpan(s.avatar_url, s.username, s.profile_color)}
${esc(s.username || 'Anonymous')}
${esc(s.username || 'Anonymous')}
@@ -2132,6 +2132,12 @@ function thumbImg(thumbnailUrl, fallbackIcon, alt, regenerateUrl = null) {
@@ -2132,6 +2132,12 @@ function thumbImg(thumbnailUrl, fallbackIcon, alt, regenerateUrl = null) {
return `<i class="fa-solid ${fallbackIcon}"></i>`;
return `<i class="fa-solid ${fallbackIcon}"></i>`;
}
}
+/** An audio-only recording or clip (Media#240/R4): its picture is Media's avatar card, and the card says so. */
+function audioChip(item) {
+ if (!item || !(item.audio_only === true || item.audio_only === 1)) return '';
+ return '<span class="stream-card-audio" title="Audio only"><i class="fa-solid fa-microphone" aria-hidden="true"></i> Audio</span>';
+}
+
function formatDuration(secs) {
function formatDuration(secs) {
if (!secs) return '0:00';
if (!secs) return '0:00';
const h = Math.floor(secs / 3600);
const h = Math.floor(secs / 3600);
@@ -2477,3 +2483,38 @@ window.addEventListener('popstate', () => {
@@ -2477,3 +2483,38 @@ window.addEventListener('popstate', () => {
// If auth hasn't loaded yet, DOMContentLoaded handler will call routeFromURL()
// If auth hasn't loaded yet, DOMContentLoaded handler will call routeFromURL()
});
});
+
+
+/* ── Content labels (L1): what a stream or recording carries, for Shared's ov-labels.js (each viewer's show, blur or
+ hide) and as chips in words. The streamer's own content always shows to them, so it is never marked for them. ── */
+const OV_LABEL_NAMES = { nudity: 'Nudity', language: 'Strong language', graphic: 'Graphic', '18+': '18+', flashing: 'Flashing lights', spoilers: 'Spoilers' };
+function ovLabelsOf(item) {
+ let raw = item && item.content_labels;
+ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
+ const list = raw && Array.isArray(raw.labels) ? raw.labels : [];
+ return [...new Set(list)].filter((l) => Object.prototype.hasOwnProperty.call(OV_LABEL_NAMES, l));
+}
+function ovIsMine(item) {
+ try { return !!(currentUser && item && item.username && String(currentUser.username).toLowerCase() === String(item.username).toLowerCase()); } catch { return false; }
+}
+/** ' data-ov-labels="…"' for a card or player (empty for none, or for the viewer's own). */
+function ovLabelsAttr(item) {
+ const labels = ovLabelsOf(item);
+ return labels.length && !ovIsMine(item) ? ` data-ov-labels="${esc(labels.join(','))}"` : '';
+}
+/** The chips, in words; 18+ in the warning colour. */
+function ovLabelChips(item) {
+ const labels = ovLabelsOf(item);
+ if (!labels.length) return '';
+ return `<ul class="ovk-labels" aria-label="Content labels">${labels.map((l) => `<li class="ovk-label${l === '18+' ? ' ovk-label--adult' : ''}" data-label="${esc(l)}">${esc(OV_LABEL_NAMES[l])}</li>`).join('')}</ul>`;
+}
+/** Mark (or unmark) an element already on the page, such as the channel's player. */
+function ovMarkLabels(el, item, { opened = false } = {}) {
+ if (!el) return;
+ const labels = ovIsMine(item) ? [] : ovLabelsOf(item);
+ if (labels.length) el.setAttribute('data-ov-labels', labels.join(','));
+ else { el.removeAttribute('data-ov-labels'); el.removeAttribute('data-ov-labels-state'); }
+ // The thing's own page (the channel's player): hide covers it rather than emptying the page (openvibe-shared 4.94.0).
+ el.toggleAttribute('data-ov-labels-opened', Boolean(opened && labels.length));
+ if (window.OVLabels && typeof window.OVLabels.apply === 'function') window.OVLabels.apply(el);
+}
@@ -310,6 +310,9 @@ function _wsRenderPanel() {
@@ -310,6 +310,9 @@ function _wsRenderPanel() {
<input type="checkbox" id="bc-nsfw" ${ms.is_nsfw ? 'checked' : ''} onchange="_wsMarkDirty()">
<input type="checkbox" id="bc-nsfw" ${ms.is_nsfw ? 'checked' : ''} onchange="_wsMarkDirty()">
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i><span>Mature</span><b id="gl-mature-v">${ms.is_nsfw ? 'on' : 'off'}</b>
<i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i><span>Mature</span><b id="gl-mature-v">${ms.is_nsfw ? 'on' : 'off'}</b>
</label>
</label>
+ <button type="button" class="gl-chip" onclick="_glOpenFold('gl-labels')" title="What this stream carries: each viewer decides to show, blur or hide it">
+ <i class="fa-solid fa-tag" aria-hidden="true"></i><span>Labels</span><b id="gl-labels-v">${esc(_glLabelsSummary(ms, true))}</b>
+ </button>
<button type="button" class="gl-chip gl-chip--link" onclick="_glCopyLink()" title="Copy your stream's address">
<button type="button" class="gl-chip gl-chip--link" onclick="_glCopyLink()" title="Copy your stream's address">
<i class="fa-solid fa-link" aria-hidden="true"></i><span id="gl-link">${esc(link)}</span>
<i class="fa-solid fa-link" aria-hidden="true"></i><span id="gl-link">${esc(link)}</span>
</button>
</button>
@@ -439,6 +442,15 @@ function _wsRenderPanel() {
@@ -439,6 +442,15 @@ function _wsRenderPanel() {
</div>
</div>
</details>
</details>
+ <details class="gl-fold" id="gl-labels">
+ <summary><i class="fa-solid fa-tag" aria-hidden="true"></i><span class="bc-sum-title">Labels</span><small class="bc-sum-val" id="gl-labels-sum">${esc(_glLabelsSummary(ms))}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
+ <div class="gl-fold-body">
+ <p class="gl-labels-why">Labels are choices, not bans. Each viewer decides whether what carries them shows, blurs until tapped or hides; the home page leaves 18+ out.</p>
+ <div class="gl-labels" id="gl-labels-pick" role="group" aria-label="Labels on this stream">${_glLabelsChips(ms)}</div>
+ <div class="gl-labels-ai" id="gl-labels-ai">${_glLabelsSuggested(ms)}</div>
+ </div>
+ </details>
+
<details class="gl-fold" id="gl-how">
<details class="gl-fold" id="gl-how">
<summary><i class="fa-solid fa-${_glMethodIcon(method, browserMode)}" aria-hidden="true"></i><span class="bc-sum-title">How you stream</span><small class="bc-sum-val" id="gl-how-sum">${esc(_glHowSummary(method, browserMode))}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<summary><i class="fa-solid fa-${_glMethodIcon(method, browserMode)}" aria-hidden="true"></i><span class="bc-sum-title">How you stream</span><small class="bc-sum-val" id="gl-how-sum">${esc(_glHowSummary(method, browserMode))}</small><i class="fa-solid fa-chevron-down bc-sum-chev" aria-hidden="true"></i></summary>
<div class="gl-fold-body bc-ws-col--method">
<div class="gl-fold-body bc-ws-col--method">
@@ -922,6 +934,8 @@ function _glSyncSummaries() {
@@ -922,6 +934,8 @@ function _glSyncSummaries() {
if (!ms) return;
if (!ms) return;
const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };
const set = (id, html) => { const el = document.getElementById(id); if (el) el.innerHTML = html; };
set('gl-details-sum', _glDetailsSummary(ms));
set('gl-details-sum', _glDetailsSummary(ms));
+ set('gl-labels-sum', esc(_glLabelsSummary(ms)));
+ set('gl-labels-v', esc(_glLabelsSummary(ms, true)));
const method = ms.streaming_method || 'browser', mode = ms.browser_mode || _wsState.profile?.browserMode || 'camera';
const method = ms.streaming_method || 'browser', mode = ms.browser_mode || _wsState.profile?.browserMode || 'camera';
set('gl-how-sum', esc(_glHowSummary(method, mode)));
set('gl-how-sum', esc(_glHowSummary(method, mode)));
const howIcon = document.querySelector('#gl-how > summary > i:first-child');
const howIcon = document.querySelector('#gl-how > summary > i:first-child');
@@ -1280,6 +1294,51 @@ function _glTestClear() {
@@ -1280,6 +1294,51 @@ function _glTestClear() {
if (_glT.url) { URL.revokeObjectURL(_glT.url); _glT.url = ''; }
if (_glT.url) { URL.revokeObjectURL(_glT.url); _glT.url = ''; }
}
}
+/* ── Labels on the stream (L1, Live #477, common.content-labels@1): the creator sets them; the AI only suggests, and a
+ suggestion is the creator's to confirm. Saved with the rest of the card (_wsSaveAll sends labels). ── */
+const _GL_LABELS = [['nudity', 'Nudity'], ['language', 'Strong language'], ['graphic', 'Graphic'], ['18+', '18+'], ['flashing', 'Flashing lights'], ['spoilers', 'Spoilers']];
+const _glLabelName = (id) => (_GL_LABELS.find(([k]) => k === id) || [id, id])[1];
+
+function _glLabelsOf(ms) {
+ let raw = ms && ms.content_labels;
+ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
+ const pick = (v) => (Array.isArray(v) ? v : []).filter((l) => _GL_LABELS.some(([k]) => k === l));
+ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };
+}
+
+function _glLabelsSummary(ms, short = false) {
+ const { labels, suggested } = _glLabelsOf(ms);
+ if (!labels.length) return suggested.length && !short ? `None · ${suggested.length} suggested` : 'none';
+ if (short && labels.length > 2) return `${labels.length}`;
+ return labels.map(_glLabelName).join(', ');
+}
+
+function _glLabelsChips(ms) {
+ const { labels } = _glLabelsOf(ms);
+ return _GL_LABELS.map(([id, name]) => `<button type="button" class="gl-label${labels.includes(id) ? ' is-on' : ''}${id === '18+' ? ' gl-label--adult' : ''}" aria-pressed="${labels.includes(id)}" onclick="_glToggleLabel('${id}')">${esc(name)}</button>`).join('');
+}
+
+function _glLabelsSuggested(ms) {
+ const { labels, suggested } = _glLabelsOf(ms);
+ const open = suggested.filter((l) => !labels.includes(l));
+ if (!open.length) return '';
+ return `<p class="gl-labels-ai-h">The AI noticed these on your last stream. Nothing changes until you add one.</p>`
+ + open.map((id) => `<button type="button" class="gl-label gl-label--ai" onclick="_glToggleLabel('${id}')">+ ${esc(_glLabelName(id))}</button>`).join('');
+}
+
+function _glToggleLabel(id) {
+ const ms = _wsState.selectedMs;
+ if (!ms || !_GL_LABELS.some(([k]) => k === id)) return;
+ const cur = _glLabelsOf(ms);
+ const labels = cur.labels.includes(id) ? cur.labels.filter((l) => l !== id) : [...cur.labels, id];
+ ms.content_labels = { labels, suggested: cur.suggested.filter((l) => !labels.includes(l)) };
+ const pick = document.getElementById('gl-labels-pick');
+ if (pick) pick.innerHTML = _glLabelsChips(ms);
+ const ai = document.getElementById('gl-labels-ai');
+ if (ai) ai.innerHTML = _glLabelsSuggested(ms);
+ _wsMarkDirty();
+}
+
/* ── Tab switching ───────────────────────────────────────────── */
/* ── Tab switching ───────────────────────────────────────────── */
function _wsTabSwitch(tabId) {
function _wsTabSwitch(tabId) {
@@ -2146,6 +2205,8 @@ async function _wsSaveAll() {
@@ -2146,6 +2205,8 @@ async function _wsSaveAll() {
weather_zip: ms.weather_zip || null,
weather_zip: ms.weather_zip || null,
weather_detail: ms.weather_detail || 'basic',
weather_detail: ms.weather_detail || 'basic',
weather_show_location: ms.weather_show_location || 0,
weather_show_location: ms.weather_show_location || 0,
+ // The creator's labels (L1): confirmed ones leave the AI's suggestions on the server too.
+ labels: _glLabelsOf(ms).labels,
};
};
const profileBlob = {
const profileBlob = {
@@ -960,6 +960,13 @@ function initPlayer(stream) {
@@ -960,6 +960,13 @@ function initPlayer(stream) {
try { window.streamPip?.attach(stream); } catch (e) { console.warn('[PiP] attach failed:', e.message); }
try { window.streamPip?.attach(stream); } catch (e) { console.warn('[PiP] attach failed:', e.message); }
setViewerPausedIntent(false); // fresh stream — always start playing
setViewerPausedIntent(false); // fresh stream — always start playing
streamRef = stream; // Store for clip recording across all protocols
streamRef = stream; // Store for clip recording across all protocols
+ // Content labels (L1): each viewer's own choice covers or hides the player (Shared ov-labels.js), and the chips
+ // under the title say what it carries.
+ try {
+ ovMarkLabels(document.getElementById('video-container'), stream, { opened: true });
+ const chips = document.getElementById('stream-labels');
+ if (chips) chips.innerHTML = ovLabelChips(stream);
+ } catch { /* labels are a courtesy: never stop playback */ }
_resetPlayerRevealState();
_resetPlayerRevealState();
_applyLoaderThumb(stream);
_applyLoaderThumb(stream);
const proto = stream.protocol || 'jsmpeg';
const proto = stream.protocol || 'jsmpeg';
@@ -22,8 +22,8 @@ const BUDGETS = {
@@ -22,8 +22,8 @@ const BUDGETS = {
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlRawKB: 128, // 119.3 with the icons drawn on the server (2026-11-01, plan L54 K4: the OpenVibe glyphs in the HTML, 20.8 KB brotli); measured 94.9 before
htmlBrotliKB: 30,
htmlBrotliKB: 30,
jsFiles: 26, // measured 23 (incl. route scripts for "/")
jsFiles: 26, // measured 23 (incl. route scripts for "/")
- jsRawKB: 1258, // 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
- jsBrotliKB: 294.4, // 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
+ jsRawKB: 1263, // 1262.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 1259.8 (2026-11-02: content labels on cards and the player, L1); 1257.5 (2026-11-01: the Catch up rails load by themselves as you swipe, no Load more on a phone); 1254.2 (2026-11-01: openvibe-shared 4.87.0), about 1255 with #534 (feed and card avatars at their size); 1253.8 (2026-11-01: avatars ask Media for their drawn width, ovImgW); 1252.3 (2026-11-01: Following in the topics row, the picked topic remembered); 1250.2 (2026-11-01: topics live now, readable chat names on light themes); 1247.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 1244.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 1243.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 1242.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face, and the ring on page moves (2026-10-31); 1238.8 with the home's featured stream once and the intro closed on phones (2026-10-31); 1237.4 with the home's two more tab blocks on phones (2026-10-30); 1233.9 with the home's Catch up tabs on phones (2026-10-30); 1230.8 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 1227.8 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 1223.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 1215.4 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); measured 1203.9 (2026-10-28: openvibe-shared 4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe, the phone sheet; 4.31.0's ✦ adds about 3 KB); 1187.2 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0: opaque themed toasts held while a menu is open, grouping, priority, System cards and the rich bell panel); 1158.8 (2026-10-28: openvibe-shared 4.25.0, the menu's orb, messages, the phone sheet and auto-hide in the navbar; the messenger opens from the bar)
+ jsBrotliKB: 295.5, // 295.1 (2026-11-02: openvibe-shared 4.94.0, the Audio chip, the go-live card's labels); 294.6 (2026-11-02: content labels on cards and the player, L1); 294.1 (2026-11-01: the Catch up rails load by themselves as you swipe); 292.6 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 291.1 with openvibe-shared 4.57.1 (the Dock's async token and scope-aware controls, 4.57.0; the tool frame's facts, 4.56.0) (2026-10-31); 290.6 with the home's two more tab blocks on phones (2026-10-30); 289.8 with the home's Catch up tabs on phones (2026-10-30); 288.2 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 287.4 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); measured 283.2 (2026-10-28: openvibe-shared 4.30.0, the bell center as above); 279.6 (2026-10-28: openvibe-shared 4.27.0, Notifications 2.0 as above); 273.1 (2026-10-28: openvibe-shared 4.25.0 as above); 270.5 (2026-10-28: openvibe-shared 4.22.0, the navbar's search magnifier with / and Ctrl+K and the More menu; search.js and loading.js load only when used); 269.6 (2026-10-28: openvibe-shared 4.19.0, the account menu's gear and people-group icons inline; 268 on 2026-10-27: openvibe-shared 4.16.0, the folded balance chip, Across OpenVibe with site marks and the icon preload in the navbar); 265.3 (2026-10-27: openvibe-shared 4.15.0, the compact footer opens into the whole one, and the pulse line); 263.3 on 10-23 (openvibe-shared 4.9.0, status text twins in every theme); 262.5 on 10-17 (openvibe-shared 4.4.0, every site in the launcher)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssRawKB: 270, // measured ~243 since the first-paint subset (style.core.css) replaced style.css (~717 before)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
cssBrotliKB: 55, // measured ~47.5 (was ~130)
};
};
@@ -199,7 +199,7 @@ async function _generateVodOverviewInner(vod) {
@@ -199,7 +199,7 @@ async function _generateVodOverviewInner(vod) {
if (!src) { try { await db.setVodAiOverview(vod.id, ' '); } catch { /* */ } return null; } // unprocessable — mark done
if (!src) { try { await db.setVodAiOverview(vod.id, ' '); } catch { /* */ } return null; } // unprocessable — mark done
const r = await ma.analyzeMedia(src, {
const r = await ma.analyzeMedia(src, {
streamId: vod.stream_id || null, userId: vod.user_id || null,
streamId: vod.stream_id || null, userId: vod.user_id || null,
- storeMemories: !!vod.stream_id, offsetBase: 0,
+ storeMemories: !!vod.stream_id, offsetBase: 0, audioOnly: !!vod.audio_only,
});
});
const overview = r && r.overview ? r.overview : ' '; // ' ' = tried, nothing to say
const overview = r && r.overview ? r.overview : ' '; // ' ' = tried, nothing to say
try { await db.setVodAiOverview(vod.id, overview); } catch { /* */ }
try { await db.setVodAiOverview(vod.id, overview); } catch { /* */ }
@@ -219,6 +219,7 @@ async function _generateVodOverviewInner(vod) {
@@ -219,6 +219,7 @@ async function _generateVodOverviewInner(vod) {
*/
*/
async function ensureVodTimeline(vod) {
async function ensureVodTimeline(vod) {
if (!vod || !vod.stream_id) return;
if (!vod || !vod.stream_id) return;
+ if (vod.audio_only) return; // no frames to anchor (Media#240/R4); the transcript pass carries the moments
if (require('./ai-media-analyze').enabled()) return;
if (require('./ai-media-analyze').enabled()) return;
if (!await isEnabled() || !await withinBudget()) return;
if (!await isEnabled() || !await withinBudget()) return;
const ma = require('./media-analysis');
const ma = require('./media-analysis');
@@ -354,6 +355,7 @@ async function generateClipOverview(clip) {
@@ -354,6 +355,7 @@ async function generateClipOverview(clip) {
const r = await require('./media-analysis').analyzeMedia(src, {
const r = await require('./media-analysis').analyzeMedia(src, {
streamId: clip.stream_id || null, userId: clip.user_id || null,
streamId: clip.stream_id || null, userId: clip.user_id || null,
numFrames: 3, storeMemories: !!clip.stream_id, offsetBase: clip.start_time || 0,
numFrames: 3, storeMemories: !!clip.stream_id, offsetBase: clip.start_time || 0,
+ audioOnly: !!clip.audio_only,
});
});
const overview = (r && r.overview) ? r.overview : ' ';
const overview = (r && r.overview) ? r.overview : ' ';
const transcript = r ? r.transcript : '';
const transcript = r ? r.transcript : '';
@@ -315,13 +315,16 @@ async function _transcribeSpan(src, duration, { resumeFromSec = 0, priorSegments
@@ -315,13 +315,16 @@ async function _transcribeSpan(src, duration, { resumeFromSec = 0, priorSegments
* descriptions (optionally stored as memories) + sampled transcript → overview.
* descriptions (optionally stored as memories) + sampled transcript → overview.
* @returns {{ overview, transcript, segments, transcriptOk, frames, duration }}
* @returns {{ overview, transcript, segments, transcriptOk, frames, duration }}
*/
*/
-async function analyzeMedia(src, { streamId = null, userId = null, numFrames = null, storeMemories = false, offsetBase = 0 } = {}) {
+async function analyzeMedia(src, { streamId = null, userId = null, numFrames = null, storeMemories = false, offsetBase = 0, audioOnly = false } = {}) {
if (!src) return { overview: null, transcript: '', frames: [], duration: 0 };
if (!src) return { overview: null, transcript: '', frames: [], duration: 0 };
// Lazy require (avoids a load-time circular dep with ai-analysis, which pulls us in).
// Lazy require (avoids a load-time circular dep with ai-analysis, which pulls us in).
const ai = require('./ai-analysis');
const ai = require('./ai-analysis');
const duration = await _ffprobeDuration(src);
const duration = await _ffprobeDuration(src);
let frames = [];
let frames = [];
- if (duration <= 2) {
+ // An audio-only recording has no frame to take (Media#240/R4): skip frame extraction entirely instead of asking
+ // ffmpeg for pictures that do not exist on every retry. The transcript is the whole analysis.
+ if (audioOnly) frames = [];
+ else if (duration <= 2) {
// Tiny media — one frame.
// Tiny media — one frame.
const one = await captureFrameMemories(src, [1], { streamId, userId, offsetBase, store: storeMemories });
const one = await captureFrameMemories(src, [1], { streamId, userId, offsetBase, store: storeMemories });
frames = one;
frames = one;
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
*/
*/
const db = require('../db/database');
const db = require('../db/database');
const ai = require('./ai-analysis');
const ai = require('./ai-analysis');
+const contentLabels = require('../labels');
let vision = null;
let vision = null;
try { vision = require('./stream-vision'); } catch { /* optional */ }
try { vision = require('./stream-vision'); } catch { /* optional */ }
@@ -150,6 +151,9 @@ async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } =
@@ -150,6 +151,9 @@ async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } =
description: memDesc, tags: r.tags, thumbnail_url: await _persistMomentFrame(image, stream.id, offset) || _stableThumb(stream.thumbnail_url),
description: memDesc, tags: r.tags, thumbnail_url: await _persistMomentFrame(image, stream.id, offset) || _stableThumb(stream.thumbnail_url),
transcript_json: heardSegments,
transcript_json: heardSegments,
});
});
+ // L1: suggest labels from what this frame shows and what was said. A suggestion is stored in `suggested`
+ // and changes nothing for anyone; the creator confirms it (PUT /api/streams/:id). Never a takedown.
+ try { await db.suggestStreamContentLabels(stream.id, contentLabels.suggestFrom({ description: r.description, transcript: heardClean })); } catch (e) { console.warn('[AI-Labels] suggestion not stored:', e.message); }
// Roll this stream's memories into an overview — but only RE-summarize when
// Roll this stream's memories into an overview — but only RE-summarize when
// enough new memories have accumulated (SUMMARY_MIN_NEW) or enough time has
// enough new memories have accumulated (SUMMARY_MIN_NEW) or enough time has
// passed (SUMMARY_MAX_AGE_MS), NOT on every 120s frame capture. This cuts the
// passed (SUMMARY_MAX_AGE_MS), NOT on every 120s frame capture. This cuts the
@@ -128,6 +128,7 @@ async function vodItem(v) {
@@ -128,6 +128,7 @@ async function vodItem(v) {
created_at: iso(v.created_at), views: num(v.view_count),
created_at: iso(v.created_at), views: num(v.view_count),
duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
thumbnail_url: media.publicUrl(v.thumbnail_url) || null,
thumbnail_url: media.publicUrl(v.thumbnail_url) || null,
+ audio_only: !!v.audio_only,
excerpt: v.description ? clean(v.description, 220) : null,
excerpt: v.description ? clean(v.description, 220) : null,
channel: person(owner), ai: false,
channel: person(owner), ai: false,
};
};
@@ -150,6 +151,7 @@ async function clipItem(c, { ai }) {
@@ -150,6 +151,7 @@ async function clipItem(c, { ai }) {
created_at: iso(c.created_at), views: num(c.view_count),
created_at: iso(c.created_at), views: num(c.view_count),
duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
thumbnail_url: media.publicUrl(c.thumbnail_url) || null,
thumbnail_url: media.publicUrl(c.thumbnail_url) || null,
+ audio_only: !!c.audio_only,
preview_url: media.publicUrl(c.playback_url) || media.clipUrl(c.id),
preview_url: media.publicUrl(c.playback_url) || media.clipUrl(c.id),
excerpt: summary ? clean(summary, 220) : (c.description ? clean(c.description, 220) : null),
excerpt: summary ? clean(summary, 220) : (c.description ? clean(c.description, 220) : null),
channel: person(channelOwner || creator), by, ai,
channel: person(channelOwner || creator), by, ai,
@@ -334,12 +334,19 @@ async function fireStreamLifecycle(kind, streamId) {
@@ -334,12 +334,19 @@ async function fireStreamLifecycle(kind, streamId) {
try { await getDb().tx(() => streamLifecycleHook(kind, streamId)); } catch (err) { console.warn(`[Events] stream ${kind} event for ${streamId} not queued:`, err.message); }
try { await getDb().tx(() => streamLifecycleHook(kind, streamId)); } catch (err) { console.warn(`[Events] stream ${kind} event for ${streamId} not queued:`, err.message); }
}
}
-async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url }) {
+async function createStream({ user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels = null }) {
return await getDb().tx(async () => {
return await getDb().tx(async () => {
+ // The live session starts with the slot's creator labels; the AI's suggestions stay on the session.
+ let labels = content_labels;
+ if (!labels && managed_stream_id) {
+ const slot = await get('SELECT content_labels FROM managed_streams WHERE id = ?', [managed_stream_id]);
+ if (slot && slot.content_labels) labels = slot.content_labels;
+ }
+ const labelJson = typeof labels === 'string' ? labels : JSON.stringify(labels || { labels: [], suggested: [] });
const result = await run(
const result = await run(
- `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, is_live, started_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,
- [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null]
+ `INSERT INTO streams (user_id, channel_id, managed_stream_id, title, description, category, protocol, is_nsfw, thumbnail_url, content_labels, is_live, started_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ov_now()) RETURNING id`,
+ [user_id, channel_id || null, managed_stream_id || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', is_nsfw ? 1 : 0, thumbnail_url || null, labelJson]
);
);
await fireStreamLifecycle('started', result.lastInsertRowid);
await fireStreamLifecycle('started', result.lastInsertRowid);
return result;
return result;
@@ -1043,11 +1050,11 @@ async function updateViewerCount(streamId, count) {
@@ -1043,11 +1050,11 @@ async function updateViewerCount(streamId, count) {
// ── Managed Stream helpers ───────────────────────────────────
// ── Managed Stream helpers ───────────────────────────────────
-async function createManagedStream({ user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw }) {
+async function createManagedStream({ user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw, content_labels = null }) {
return await run(
return await run(
- `INSERT INTO managed_streams (user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
- [user_id, channel_id || null, slug || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', streaming_method || null, stream_key, is_nsfw ? 1 : 0]
+ `INSERT INTO managed_streams (user_id, channel_id, slug, title, description, category, protocol, streaming_method, stream_key, is_nsfw, content_labels)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
+ [user_id, channel_id || null, slug || null, title || 'Untitled Stream', description || '', category || null, protocol || 'webrtc', streaming_method || null, stream_key, is_nsfw ? 1 : 0, typeof content_labels === 'string' ? content_labels : JSON.stringify(content_labels || { labels: [], suggested: [] })]
);
);
}
}
@@ -1114,14 +1121,14 @@ async function updateManagedStream(managedStreamId, userId, fields) {
@@ -1114,14 +1121,14 @@ async function updateManagedStream(managedStreamId, userId, fields) {
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
'default_vod_visibility', 'default_clip_visibility', 'slot_vod_recording_enabled', 'slot_clip_recording_enabled',
'slot_clip_notify_enabled', 'slot_powerchat_relay', 'slot_powerchat_count_rs_views',
'slot_clip_notify_enabled', 'slot_powerchat_relay', 'slot_powerchat_count_rs_views',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
'weather_zip', 'weather_detail', 'weather_show_location', 'mic_only_image',
- 'pip_source_msid', 'pip_defaults',
+ 'pip_source_msid', 'pip_defaults', 'content_labels',
]);
]);
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 && allowed.has(key)) {
if (val !== undefined && allowed.has(key)) {
updates.push(`${key} = ?`);
updates.push(`${key} = ?`);
- params.push(['tags', 'pip_defaults'].includes(key)
+ params.push(['tags', 'pip_defaults', 'content_labels'].includes(key)
? (typeof val === 'string' ? val : JSON.stringify(val))
? (typeof val === 'string' ? val : JSON.stringify(val))
: val);
: val);
}
}
@@ -1132,6 +1139,30 @@ async function updateManagedStream(managedStreamId, userId, fields) {
@@ -1132,6 +1139,30 @@ async function updateManagedStream(managedStreamId, userId, fields) {
return await run(`UPDATE managed_streams SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, params);
return await run(`UPDATE managed_streams SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`, params);
}
}
+// ── Content labels (migration 0023, L1 labels and choices) ───
+// A creator's labels live on the slot and are copied to each live session. The AI's suggestions are stored on the
+// session and never applied; a suggestion the creator confirms leaves the suggested list. None of this hides
+// anything from anyone: a label is read by the page and the viewer's network.label-prefs@1 decides (except the home
+// page, which skips 18+).
+/** A slot's labels changed: the live session shows them at once, keeping the AI suggestions it already has. */
+async function setLiveStreamsContentLabels(managedStreamId, labels) {
+ return await run(`UPDATE streams SET content_labels = jsonb_set(content_labels, '{labels}', ?::jsonb) WHERE managed_stream_id = ? AND is_live = 1`, [JSON.stringify(labels || []), managedStreamId]);
+}
+
+/** Add AI suggestions to a live session. Read-modify-write in a transaction so two frames never lose a suggestion. */
+async function suggestStreamContentLabels(streamId, labels) {
+ const shape = require('../labels');
+ return await getDb().tx(async () => {
+ const row = await get('SELECT content_labels FROM streams WHERE id = ? FOR UPDATE', [streamId]);
+ if (!row) return null;
+ const current = shape.labelsOf(row);
+ const next = shape.shape(current.labels, [...current.suggested, ...(labels || [])]);
+ if (next.suggested.length === current.suggested.length) return current;
+ await run('UPDATE streams SET content_labels = ? WHERE id = ?', [JSON.stringify(next), streamId]);
+ return next;
+ });
+}
+
/**
/**
* Resolve the picture-in-picture camera overlay for a slot.
* Resolve the picture-in-picture camera overlay for a slot.
*
*
@@ -3654,6 +3685,7 @@ module.exports = {
@@ -3654,6 +3685,7 @@ module.exports = {
createManagedStream, getManagedStreamById, getManagedStreamsByUserId,
createManagedStream, getManagedStreamById, getManagedStreamsByUserId,
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
getManagedStreamBySlug, getManagedStreamByStreamKey, getManagedStreamByIdOrSlug,
updateManagedStream, deleteManagedStream,
updateManagedStream, deleteManagedStream,
+ setLiveStreamsContentLabels, suggestStreamContentLabels,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
getManagedStreamBroadcastSettings, updateManagedStreamBroadcastSettings,
getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, getLiveOwnPipSourceSlots, MAX_PIPS,
getPipOverlayForManagedStream, getPipCandidateSlots, getPipsForManagedStream, setPipsForManagedStream, getPipSourceSlots, getLiveOwnPipSourceSlots, MAX_PIPS,
countManagedStreamsByUser, getManagedStreamLimit,
countManagedStreamsByUser, getManagedStreamLimit,
@@ -477,6 +477,7 @@ router.get('/digest', optionalAuth, async (req, res) => {
@@ -477,6 +477,7 @@ router.get('/digest', optionalAuth, async (req, res) => {
const followed = new Set(uid ? (await db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [uid]) || []).map(r => r.streamer_id) : []);
const followed = new Set(uid ? (await db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [uid]) || []).map(r => r.streamer_id) : []);
const liveNow = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_at
const liveNow = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_at
FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0
FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0
+ AND NOT (s.content_labels->'labels' @> '["18+"]'::jsonb)
ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10`) || []).map(r => ({ ...r, followed: followed.has(r.user_id) }));
ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10`) || []).map(r => ({ ...r, followed: followed.has(r.user_id) }));
const liveIds = new Set(liveNow.map(r => r.user_id));
const liveIds = new Set(liveNow.map(r => r.user_id));
const streamedRows = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color,
const streamedRows = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color,
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+/**
+ * Content labels (L1, labels and choices, 2026-10-31; Contracts 0.257.0 common.content-labels@1).
+ *
+ * The one label list, the shape a stream stores and returns, the creator's write, and the AI's *suggestion*.
+ * A label is never a ban: it never hides content from the person who posted it, and it changes nothing until the
+ * creator confirms it. The AI only ever writes to `suggested`; a suggestion never moves itself into `labels`.
+ */
+const LABELS = Object.freeze(['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']);
+const SHAPE = Object.freeze({ labels: Object.freeze([]), suggested: Object.freeze([]) });
+
+const isLabel = (x) => typeof x === 'string' && LABELS.includes(x);
+
+/** A clean label list from a creator's request, or null when it is not a list of known labels. */
+function parseLabels(value) {
+ if (!Array.isArray(value)) return null;
+ const out = [];
+ for (const label of value) {
+ if (!isLabel(label)) return null;
+ if (!out.includes(label)) out.push(label);
+ }
+ return out;
+}
+
+/** The contract's shape from a jsonb column, a JSON string, or nothing: always two arrays of known labels. */
+function labelsOf(row) {
+ let raw = row && typeof row === 'object' && 'content_labels' in row ? row.content_labels : row;
+ if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
+ const pick = (v) => [...new Set((Array.isArray(v) ? v : []).filter(isLabel))];
+ return { labels: pick(raw && raw.labels), suggested: pick(raw && raw.suggested) };
+}
+
+/** The shape to store: `labels` are in force, `suggested` is still a suggestion. */
+const shape = (labels, suggested = []) => ({ labels: [...new Set((labels || []).filter(isLabel))], suggested: [...new Set((suggested || []).filter(isLabel))] });
+
+/** Apply a creator's labels and drop the ones they just confirmed from the suggestions. */
+function confirmed(current, labels) {
+ const now = labelsOf(current);
+ return { labels: [...labels], suggested: now.suggested.filter((l) => !labels.includes(l)) };
+}
+
+// What the AI sees in the frame notes and hears in the transcript. Narrow on purpose: a label is a suggestion for
+// the creator, not a verdict, and it never touches the content itself.
+const NUDITY = /\b(?:nude|naked|nudity|topless|undressed|bare\s+(?:chest|breasts?|bottom)|explicit(?:ly)?\s+(?:adult|sexual)|sexual\s+(?:acts?|content|activity|material)|porn(?:o|ography|ographic)?|erotic\w*|genitals?|masturbat\w*|intercourse)\b/i;
+const GRAPHIC = /\b(?:blood(?:y|ied)?|gore|gory|graphic\s+(?:violence|injury)|severe\s+injur\w*|dead\s+body|corpse|mutilat\w*|dismember\w*)\b/i;
+const FLASHING = /\b(?:strobe[sd]?|strobing|flashing\s+lights?|seizure\s+warning|epilep\w*)\b/i;
+const LANGUAGE = /\b(?:fuck\w*|shit(?:ty|s|ting)?|bitch\w*|cunts?|assholes?|bastards?|dick(?:s|head)?|puss(?:y|ies)|cock(?:s|sucker)?)\b/i;
+
+/**
+ * What the AI suggests from one frame's notes and the transcript it heard: nudity from the scene (and 18+ with it),
+ * graphic violence and flashing lights from the scene, strong language from what was said. Spoilers cannot be
+ * guessed from a frame, so they are never suggested. Order follows the label list.
+ */
+function suggestFrom({ description, transcript } = {}) {
+ const scene = String(description || '');
+ const heard = String(transcript || '');
+ const found = new Set();
+ if (NUDITY.test(scene) || NUDITY.test(heard)) { found.add('nudity'); found.add('18+'); }
+ if (LANGUAGE.test(heard)) found.add('language');
+ if (GRAPHIC.test(scene)) found.add('graphic');
+ if (FLASHING.test(scene)) found.add('flashing');
+ return LABELS.filter((l) => found.has(l));
+}
+
+module.exports = { LABELS, SHAPE, isLabel, parseLabels, labelsOf, shape, confirmed, suggestFrom };
@@ -12,6 +12,7 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
@@ -12,6 +12,7 @@ const { requireAuth, optionalAuth } = require('../auth/auth');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
const purge = require('./purge');
const purge = require('./purge');
+const contentLabels = require('../labels');
const { commentCount } = require('./comments');
const { commentCount } = require('./comments');
const lineage = require('../lineage/resolver');
const lineage = require('../lineage/resolver');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
@@ -372,6 +373,23 @@ router.put('/:id/title', requireAuth, async (req, res) => {
@@ -372,6 +373,23 @@ router.put('/:id/title', requireAuth, async (req, res) => {
}
}
});
});
+// L1: the creator (or a channel moderator) labels the clip. Media stores `{ labels, suggested }`; a suggestion the
+// creator confirms leaves the suggested list. A label never hides the clip from anyone by itself.
+router.put('/:id/labels', requireAuth, async (req, res) => {
+ try {
+ let clip;
+ try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
+ if (!clip) return res.status(404).json({ error: 'Clip not found' });
+ if (!(await canActorModerateClip(req.user, clip))) return await refuse(req, res, clip, 'Not authorized to edit this clip');
+ const nextLabels = contentLabels.parseLabels((req.body || {}).labels);
+ if (nextLabels === null) return res.status(400).json({ error: 'labels must be a list of known labels' });
+ const out = await media.updateClip(clip.id, { labels: nextLabels });
+ res.json({ message: 'Clip labels updated', content_labels: (out && out.clip && out.clip.content_labels) || { labels: nextLabels, suggested: [] } });
+ } catch (err) {
+ mediaErr(res, err, 'Failed to update clip labels');
+ }
+});
+
router.put('/:id/visibility', requireAuth, async (req, res) => {
router.put('/:id/visibility', requireAuth, async (req, res) => {
try {
try {
let clip;
let clip;
@@ -27,6 +27,7 @@ const permissions = require('../auth/permissions');
@@ -27,6 +27,7 @@ const permissions = require('../auth/permissions');
const access = require('./access');
const access = require('./access');
const purge = require('./purge');
const purge = require('./purge');
const { commentCount } = require('./comments');
const { commentCount } = require('./comments');
+const contentLabels = require('../labels');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const router = express.Router();
const router = express.Router();
@@ -652,11 +653,16 @@ router.put('/:id', requireAuth, async (req, res) => {
@@ -652,11 +653,16 @@ router.put('/:id', requireAuth, async (req, res) => {
if (!vod) return;
if (!vod) return;
try {
try {
const { title, description, is_public, visibility } = req.body || {};
const { title, description, is_public, visibility } = req.body || {};
+ // L1: a creator labels a VOD here (MediaHub's manage form and the SPA both call this route), and a
+ // suggestion the creator confirms leaves `suggested`. Media stores the shape; Live forwards it.
+ const nextLabels = req.body && req.body.labels !== undefined ? contentLabels.parseLabels(req.body.labels) : undefined;
+ if (nextLabels === null) return res.status(400).json({ error: 'labels must be a list of known labels' });
const fields = {};
const fields = {};
if (title !== undefined) fields.title = title;
if (title !== undefined) fields.title = title;
if (description !== undefined) fields.description = description;
if (description !== undefined) fields.description = description;
if (visibility !== undefined) fields.visibility = visibility;
if (visibility !== undefined) fields.visibility = visibility;
else if (is_public !== undefined) fields.visibility = is_public ? 'public' : 'private';
else if (is_public !== undefined) fields.visibility = is_public ? 'public' : 'private';
+ if (nextLabels !== undefined) fields.labels = nextLabels;
await media.updateVod(req.params.id, fields);
await media.updateVod(req.params.id, fields);
res.json({ vod: await media.getVod(req.params.id).catch(() => ({ ...vod, ...fields })) });
res.json({ vod: await media.getVod(req.params.id).catch(() => ({ ...vod, ...fields })) });
} catch (err) {
} catch (err) {
@@ -505,9 +505,10 @@ async function _homeMeta() {
@@ -505,9 +505,10 @@ async function _homeMeta() {
const title = 'OpenVibe.Live — Go Live From Your Browser, No OBS Needed';
const title = 'OpenVibe.Live — Go Live From Your Browser, No OBS Needed';
const description = 'Go live instantly from your browser: no OBS, no downloads, no follower minimum. Open-source, community-run streaming with sub-second WebRTC, chat, VODs, clips and restreaming to Twitch, YouTube and Kick.';
const description = 'Go live instantly from your browser: no OBS, no downloads, no follower minimum. Open-source, community-run streaming with sub-second WebRTC, chat, VODs, clips and restreaming to Twitch, YouTube and Kick.';
- // Pull the actual live content so the source has real, crawlable text.
+ // Pull the actual live content so the source has real, crawlable text. The home page is the owner's clean
+ // page (L1): a stream labelled 18+ is never listed here, whatever a viewer's prefs say.
let live = [];
let live = [];
- try { live = (await db.getLiveStreams() || []).slice(0, 12); } catch { /* */ }
+ try { live = (await db.getLiveStreams() || []).filter((s) => !(require('../labels').labelsOf(s).labels.includes('18+'))).slice(0, 12); } catch { /* */ }
// Each rail waits at most LIST_WAIT_MS: a slow upstream leaves its rail out, never the page.
// Each rail waits at most LIST_WAIT_MS: a slow upstream leaves its rail out, never the page.
// VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.
// VODs, clips and pastes are counted by the services that hold them (Media, Community), not Live's frozen tables.
let homeStats = null;
let homeStats = null;
@@ -24,6 +24,7 @@
@@ -24,6 +24,7 @@
*/
*/
const express = require('express');
const express = require('express');
const db = require('../db/database');
const db = require('../db/database');
+const contentLabels = require('../labels');
const { can } = require('../auth/permissions');
const { can } = require('../auth/permissions');
const config = require('../config');
const config = require('../config');
const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');
const { requireAuth, requireStreamer, optionalAuth } = require('../auth/auth');
@@ -1182,7 +1183,10 @@ router.get('/', optionalAuth, async (req, res) => {
@@ -1182,7 +1183,10 @@ router.get('/', optionalAuth, async (req, res) => {
const restreamManager = require('./restream-manager');
const restreamManager = require('./restream-manager');
// A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.
// A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.
const insidePip = await db.getLiveOwnPipSourceSlots();
const insidePip = await db.getLiveOwnPipSourceSlots();
- let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));
+ // The home page is the owner's one clean page (L1): nothing labelled 18+ is listed there, whatever a
+ // viewer's prefs say. Every other surface shows it with its chips; this is the only place a label hides.
+ let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id)))
+ && !contentLabels.labelsOf(s).labels.includes('18+'));
// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.
// Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.
// A stream matches on its own tags or the AI's, without carrying either into the response.
// A stream matches on its own tags or the AI's, without carrying either into the response.
if (wantedTags.length) {
if (wantedTags.length) {
@@ -1672,6 +1676,8 @@ router.post('/managed', requireAuth, async (req, res) => {
@@ -1672,6 +1676,8 @@ router.post('/managed', requireAuth, async (req, res) => {
const category = cleanText(req.body.category, { maxLength: MAX_CATEGORY_LENGTH }) || null; // empty = let the AI infer it from the stream
const category = cleanText(req.body.category, { maxLength: MAX_CATEGORY_LENGTH }) || null; // empty = let the AI infer it from the stream
const protocol = cleanProtocol(req.body.protocol) || 'webrtc';
const protocol = cleanProtocol(req.body.protocol) || 'webrtc';
const is_nsfw = cleanBooleanFlag(req.body.is_nsfw);
const is_nsfw = cleanBooleanFlag(req.body.is_nsfw);
+ const labels = hasOwn(req.body, 'labels') ? contentLabels.parseLabels(req.body.labels) : [];
+ if (labels === null) return res.status(400).json({ error: 'Invalid labels' });
let slug = req.body.slug ? req.body.slug.trim().toLowerCase() : null;
let slug = req.body.slug ? req.body.slug.trim().toLowerCase() : null;
if (slug) {
if (slug) {
@@ -1700,6 +1706,7 @@ router.post('/managed', requireAuth, async (req, res) => {
@@ -1700,6 +1706,7 @@ router.post('/managed', requireAuth, async (req, res) => {
streaming_method: cleanText(req.body.streaming_method, { maxLength: 20 }) || null,
streaming_method: cleanText(req.body.streaming_method, { maxLength: 20 }) || null,
stream_key,
stream_key,
is_nsfw,
is_nsfw,
+ content_labels: contentLabels.shape(labels),
});
});
const managedStream = await db.getManagedStreamById(result.lastInsertRowid);
const managedStream = await db.getManagedStreamById(result.lastInsertRowid);
@@ -1748,6 +1755,13 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
@@ -1748,6 +1755,13 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
fields.tags = cleanTags(req.body.tags);
fields.tags = cleanTags(req.body.tags);
if (fields.tags === null) return res.status(400).json({ error: 'Invalid tags' });
if (fields.tags === null) return res.status(400).json({ error: 'Invalid tags' });
}
}
+ if (hasOwn(req.body, 'labels')) {
+ // The creator's labels (L1): applied to the slot and its live session; a confirmed suggestion leaves
+ // the suggested list. Labels never hide anything here; the viewer's Network prefs decide.
+ const labels = contentLabels.parseLabels(req.body.labels);
+ if (labels === null) return res.status(400).json({ error: 'Invalid labels' });
+ fields.content_labels = contentLabels.confirmed(ms.content_labels, labels);
+ }
if (hasOwn(req.body, 'pip_source_msid')) {
if (hasOwn(req.body, 'pip_source_msid')) {
// null / '' clears the overlay. Anything else must be one of this user's own
// null / '' clears the overlay. Anything else must be one of this user's own
// slots and must not be this slot, which would ask the player to render a
// slots and must not be this slot, which would ask the player to render a
@@ -1855,6 +1869,7 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
@@ -1855,6 +1869,7 @@ router.put('/managed/:id', requireAuth, async (req, res) => {
await db.setPipsForManagedStream(msId, fields.pip_source_msid ? [{ source_msid: fields.pip_source_msid, ...d }] : []);
await db.setPipsForManagedStream(msId, fields.pip_source_msid ? [{ source_msid: fields.pip_source_msid, ...d }] : []);
}
}
const updated = await db.getManagedStreamById(msId);
const updated = await db.getManagedStreamById(msId);
+ if (fields.content_labels) await db.setLiveStreamsContentLabels(msId, contentLabels.labelsOf(updated).labels);
// If the title changed and this slot has a RobotStreamer integration, mirror the
// If the title changed and this slot has a RobotStreamer integration, mirror the
// new title to the RS robot name (best-effort, non-blocking).
// new title to the RS robot name (best-effort, non-blocking).
@@ -2232,11 +2247,15 @@ router.put('/:id', requireAuth, async (req, res) => {
@@ -2232,11 +2247,15 @@ router.put('/:id', requireAuth, async (req, res) => {
const description = cleanText(req.body.description, { maxLength: MAX_DESCRIPTION_LENGTH, allowEmpty: true });
const description = cleanText(req.body.description, { maxLength: MAX_DESCRIPTION_LENGTH, allowEmpty: true });
const category = cleanText(req.body.category, { maxLength: MAX_CATEGORY_LENGTH });
const category = cleanText(req.body.category, { maxLength: MAX_CATEGORY_LENGTH });
const tags = cleanTags(req.body.tags);
const tags = cleanTags(req.body.tags);
+ // The creator confirms or clears labels on the live session too (L1): this applies them and drops the
+ // confirmed ones from the AI's suggestions. A label never hides the stream from its own creator.
+ const labels = hasOwn(req.body, 'labels') ? contentLabels.parseLabels(req.body.labels) : undefined;
if ((hasOwn(req.body, 'title') && title === null)
if ((hasOwn(req.body, 'title') && title === null)
|| (hasOwn(req.body, 'description') && description === null)
|| (hasOwn(req.body, 'description') && description === null)
|| (hasOwn(req.body, 'category') && category === null)
|| (hasOwn(req.body, 'category') && category === null)
- || (hasOwn(req.body, 'tags') && tags === null)) {
+ || (hasOwn(req.body, 'tags') && tags === null)
+ || labels === null) {
return res.status(400).json({ error: 'Invalid stream update' });
return res.status(400).json({ error: 'Invalid stream update' });
}
}
@@ -2256,6 +2275,7 @@ router.put('/:id', requireAuth, async (req, res) => {
@@ -2256,6 +2275,7 @@ router.put('/:id', requireAuth, async (req, res) => {
}
}
}
}
if (tags !== undefined) { updates.push('tags = ?'); params.push(JSON.stringify(tags)); }
if (tags !== undefined) { updates.push('tags = ?'); params.push(JSON.stringify(tags)); }
+ if (labels !== undefined) { updates.push('content_labels = ?'); params.push(JSON.stringify(contentLabels.confirmed(stream.content_labels, labels))); }
if (updates.length > 0) {
if (updates.length > 0) {
params.push(req.params.id);
params.push(req.params.id);
@@ -75,7 +75,7 @@ const FEED_ITEM_FIELDS = {
@@ -75,7 +75,7 @@ const FEED_ITEM_FIELDS = {
views: 'number', likes: 'number', duration_seconds: 'number',
views: 'number', likes: 'number', duration_seconds: 'number',
thumbnail_url: 'string', preview_url: 'string', image_url: 'string',
thumbnail_url: 'string', preview_url: 'string', image_url: 'string',
excerpt: 'string', paste_type: 'string', language: 'string', nsfw: 'boolean',
excerpt: 'string', paste_type: 'string', language: 'string', nsfw: 'boolean',
- ai: 'boolean', ai_label: 'string', moment_href: 'string', grade: 'string', stream_title: 'string',
+ ai: 'boolean', ai_label: 'string', moment_href: 'string', grade: 'string', stream_title: 'string', audio_only: 'boolean',
};
};
const FEED_PERSON_FIELDS = ['username', 'display_name', 'avatar_url', 'profile_color', 'href'];
const FEED_PERSON_FIELDS = ['username', 'display_name', 'avatar_url', 'profile_color', 'href'];
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+'use strict';
+// Card chips for an audio-only recording or clip (Media#240/R4): the picture is Media's avatar card, and the card says
+// "Audio". The chip is drawn only when the item says audio_only, so every other card is unchanged.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app.js'), 'utf8');
+const between = (startMark, endMark) => { const a = js.indexOf(startMark); const b = js.indexOf(endMark, a); assert.ok(a >= 0 && b > a, startMark); return js.slice(a, b); };
+const ctx = vm.createContext({});
+vm.runInContext(`${between('function audioChip(item) {', '\nfunction formatDuration(')}\nthis.audioChip = audioChip;`, ctx);
+
+const chip = ctx.audioChip({ audio_only: true });
+assert.ok(chip.includes('class="stream-card-audio"') && chip.includes('Audio'), chip);
+assert.ok(chip.includes('fa-microphone'), 'a microphone says why there is no picture');
+assert.strictEqual(ctx.audioChip({ audio_only: 1 }), chip, 'Media may hand the boolean back as 1');
+assert.strictEqual(ctx.audioChip({ audio_only: false }), '');
+assert.strictEqual(ctx.audioChip({}), '');
+assert.strictEqual(ctx.audioChip(null), '');
+
+// The channel's VOD list and the home rails use it; the stylesheet places it on the picture.
+const channel = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-channel.js'), 'utf8');
+const home = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');
+assert.match(channel, /audioChip\(v\)/, 'the channel VOD list');
+assert.match(home, /audioChip\(v\)/, 'the home recordings rail');
+assert.match(home, /audioChip\(c\)/, 'the home clips rail');
+assert.match(css, /\.stream-card-audio \{[^}]*position: absolute;[^}]*top: 8px; left: 8px;/);
+console.log('audio-only chip: all passed');
@@ -48,7 +48,7 @@ let VODS = [], CLIPS = [];
@@ -48,7 +48,7 @@ let VODS = [], CLIPS = [];
const vod = (id, mins, extra = {}) => ({ id, user_id: 3, title: `VOD ${id}`, visibility: 'public', is_public: true, status: 'ready', duration_seconds: 600, view_count: 0, created_at: at(mins), thumbnail_url: `/t/vod-${id}.jpg`, file_path: `vod-${id}.webm`, storage_key: 'secret/key', ...extra });
const vod = (id, mins, extra = {}) => ({ id, user_id: 3, title: `VOD ${id}`, visibility: 'public', is_public: true, status: 'ready', duration_seconds: 600, view_count: 0, created_at: at(mins), thumbnail_url: `/t/vod-${id}.jpg`, file_path: `vod-${id}.webm`, storage_key: 'secret/key', ...extra });
const clip = (id, mins, extra = {}) => ({ id, user_id: 4, channel_user_id: 3, vod_id: 1, title: `Clip ${id}`, visibility: 'public', is_public: true, status: 'ready', auto_generated: false, duration_seconds: 20, view_count: 0, created_at: at(mins), thumbnail_url: `/t/clip-${id}.jpg`, playback_url: `https://media.test/c/${id}`, file_path: `clip-${id}.webm`, ...extra });
const clip = (id, mins, extra = {}) => ({ id, user_id: 4, channel_user_id: 3, vod_id: 1, title: `Clip ${id}`, visibility: 'public', is_public: true, status: 'ready', auto_generated: false, duration_seconds: 20, view_count: 0, created_at: at(mins), thumbnail_url: `/t/clip-${id}.jpg`, playback_url: `https://media.test/c/${id}`, file_path: `clip-${id}.webm`, ...extra });
VODS = [
VODS = [
- vod(1, 10, { view_count: 50 }), vod(2, 30, { view_count: 5 }), vod(3, 50, { view_count: 500 }), vod(4, 70),
+ vod(1, 10, { view_count: 50 }), vod(2, 30, { view_count: 5, audio_only: true }), vod(3, 50, { view_count: 500 }), vod(4, 70),
vod(5, 90, { visibility: 'private', is_public: false, title: 'SECRET VOD' }),
vod(5, 90, { visibility: 'private', is_public: false, title: 'SECRET VOD' }),
vod(6, 95, { visibility: 'unlisted', is_public: false, title: 'UNLISTED VOD' }),
vod(6, 95, { visibility: 'unlisted', is_public: false, title: 'UNLISTED VOD' }),
vod(7, 100, { user_id: 9, title: 'Banned streamer VOD' }),
vod(7, 100, { user_id: 9, title: 'Banned streamer VOD' }),
@@ -204,10 +204,12 @@ server.on('listening', async () => {
@@ -204,10 +204,12 @@ server.on('listening', async () => {
assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && c.act.liveUserId == null), 'Community is read as nobody in particular');
assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && c.act.liveUserId == null), 'Community is read as nobody in particular');
const r = await get('/api/content/feed?limit=30');
const r = await get('/api/content/feed?limit=30');
assert.deepStrictEqual(findSecrets(r.json), []);
assert.deepStrictEqual(findSecrets(r.json), []);
- const allowed = new Set(['key', 'kind', 'id', 'href', 'title', 'created_at', 'views', 'likes', 'duration_seconds', 'thumbnail_url', 'preview_url', 'image_url', 'excerpt', 'paste_type', 'language', 'nsfw', 'ai', 'ai_label', 'moment_href', 'grade', 'stream_title', 'channel', 'by']);
+ const allowed = new Set(['key', 'kind', 'id', 'href', 'title', 'created_at', 'views', 'likes', 'duration_seconds', 'thumbnail_url', 'preview_url', 'image_url', 'excerpt', 'paste_type', 'language', 'nsfw', 'ai', 'ai_label', 'moment_href', 'grade', 'stream_title', 'audio_only', 'channel', 'by']);
for (const it of r.json.items) for (const k of Object.keys(it)) assert.ok(allowed.has(k), `unexpected field ${k} on ${it.key}`);
for (const it of r.json.items) for (const k of Object.keys(it)) assert.ok(allowed.has(k), `unexpected field ${k} on ${it.key}`);
const v1 = r.json.items.find((i) => i.key === 'vod:1');
const v1 = r.json.items.find((i) => i.key === 'vod:1');
assert.strictEqual(v1.thumbnail_url, `${media.MEDIA_PUBLIC_URL}/t/vod-1.jpg`, 'Media paths become absolute');
assert.strictEqual(v1.thumbnail_url, `${media.MEDIA_PUBLIC_URL}/t/vod-1.jpg`, 'Media paths become absolute');
+ assert.strictEqual(v1.audio_only, false, 'a picture recording says so');
+ assert.strictEqual((await get('/api/content/feed?type=vods&limit=30')).json.items.find((i) => i.key === 'vod:2').audio_only, true, 'an audio-only recording carries the flag to the card');
assert.deepStrictEqual(v1.channel, { username: 'alice', display_name: 'ALICE', avatar_url: 'https://media.test/a/alice.png', profile_color: '#8b5cf6', href: '/@alice' });
assert.deepStrictEqual(v1.channel, { username: 'alice', display_name: 'ALICE', avatar_url: 'https://media.test/a/alice.png', profile_color: '#8b5cf6', href: '/@alice' });
assert.ok(!('file_path' in v1) && !('storage_key' in v1));
assert.ok(!('file_path' in v1) && !('storage_key' in v1));
const c11 = r.json.items.find((i) => i.key === 'clip:11');
const c11 = r.json.items.find((i) => i.key === 'clip:11');
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+// Content labels (L1, 2026-10-31; Contracts 0.257.0 common.content-labels@1): a creator labels a stream slot, each
+// live session copies them, the channel page and the watch screen return them, the home list skips 18+, and the AI's
+// suggestion is stored in `suggested` and never applied until the creator confirms it.
+// node test/content-labels.test.js
+const assert = require('assert');
+const http = require('http');
+process.env.NODE_ENV = 'test';
+console.log = () => {};
+console.warn = () => {};
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const raw = db.getDb();
+
+ // Requests carry their user in x-test-user (the auth middleware is replaced before the routes load).
+ const auth = require('../server/auth/auth');
+ const signIn = async (req) => { const u = await db.getUserById(Number(req.headers['x-test-user'] || 0)); if (u) req.user = u; return u; };
+ auth.requireAuth = async (req, res, next) => ((await signIn(req)) ? next() : res.status(401).json({ error: 'Authentication required' }));
+ auth.optionalAuth = async (req, res, next) => { await signIn(req); next(); };
+ require('../server/integrations/robotstreamer-service').startForStream = async () => {};
+ require('../server/integrations/chat-relay-service').startForStream = async () => {};
+ require('../server/streaming/live-events').announceGoLive = () => {};
+
+ await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role) OVERRIDING SYSTEM VALUE VALUES (1, 'labels', 'Labels', 'l@x', 'x', 'streamer')`).run();
+ const channel = await db.ensureChannel(1);
+
+ 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, '127.0.0.1');
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}/api/streams`;
+ const call = (method, path, body) => fetch(base + path, {
+ method, headers: { 'content-type': 'application/json', 'x-test-user': '1' }, body: body ? JSON.stringify(body) : undefined,
+ }).then(async (r) => ({ status: r.status, body: await r.json() }));
+ const makeSlot = (title, labels) => db.createManagedStream({
+ user_id: 1, channel_id: channel.id, title, protocol: 'webrtc',
+ stream_key: `${title}`.repeat(32).slice(0, 32), content_labels: { labels, suggested: [] },
+ }).then((r) => Number(r.lastInsertRowid || r.id));
+ const labelsOf = (body) => require('../server/labels').labelsOf(body);
+
+ const { suggestFrom } = require('../server/labels');
+ let pass = 0;
+ const ok = () => { pass++; };
+
+ try {
+ // The AI's suggestion: nudity and 18+ from the scene, language from the transcript; spoilers are never guessed.
+ assert.deepStrictEqual(suggestFrom({ description: 'The streamer appears naked in the room', transcript: 'that was fucking wild' }), ['nudity', 'language', '18+']);
+ assert.deepStrictEqual(suggestFrom({ description: 'Blood covers the floor after a graphic fight' }), ['graphic']);
+ assert.deepStrictEqual(suggestFrom({ description: 'A strobing light fills the stage' }), ['flashing']);
+ assert.deepStrictEqual(suggestFrom({ description: 'A player wins a round in Fortnite', transcript: 'nice shot' }), []);
+ ok('the suggester reads the scene and the transcript, never a spoiler');
+
+ // Two slots: one plain, one labelled 18+. Their live sessions carry the labels.
+ const plain = await makeSlot('plain', ['language']);
+ const adult = await makeSlot('adult', ['18+']);
+ const plainLive = await call('POST', '', { managed_stream_id: plain, title: 'A quiet stream' });
+ const adultLive = await call('POST', '', { managed_stream_id: adult, title: 'An adult stream' });
+ assert.strictEqual(plainLive.status, 201, JSON.stringify(plainLive.body));
+ assert.strictEqual(adultLive.status, 201, JSON.stringify(adultLive.body));
+ assert.deepStrictEqual(labelsOf(plainLive.body.stream).labels, ['language'], 'a live session copies the slot labels');
+ assert.deepStrictEqual(labelsOf(adultLive.body.stream).labels, ['18+']);
+
+ // The home list (GET /api/streams, what the home live rail polls) skips 18+; the channel page does not.
+ const home = await call('GET', '');
+ const homeIds = home.body.streams.map((s) => Number(s.id));
+ assert.ok(homeIds.includes(Number(plainLive.body.stream.id)), 'the plain stream is on the home list');
+ assert.ok(!homeIds.includes(Number(adultLive.body.stream.id)), 'nothing labelled 18+ is on the home list');
+ assert.ok(!JSON.stringify(home.body).includes('An adult stream'), 'the 18+ stream is not in the home payload at all');
+ const onChannel = await call('GET', '/channel/labels/live');
+ const shown = onChannel.body.streams.find((s) => Number(s.id) === Number(adultLive.body.stream.id));
+ assert.ok(shown, 'the channel page still shows it');
+ assert.deepStrictEqual(labelsOf(shown).labels, ['18+'], 'with its chip');
+ ok('the home page skips 18+; the channel page shows it with its label');
+
+ // The AI suggests on the plain stream: stored as suggested, never applied.
+ const streamId = Number(plainLive.body.stream.id);
+ const suggested = await db.suggestStreamContentLabels(streamId, ['nudity', '18+']);
+ assert.deepStrictEqual(suggested.suggested, ['nudity', '18+']);
+ let live = await call('GET', `/${streamId}`);
+ assert.deepStrictEqual(labelsOf(live.body.stream), { labels: ['language'], suggested: ['nudity', '18+'] }, 'a suggestion changes no label');
+ ok('the AI suggestion is stored as suggested, never applied');
+
+ // The creator confirms: the labels apply, the confirmed ones leave the suggestions.
+ let r = await call('PUT', `/${streamId}`, { labels: ['language', 'nudity', '18+'] });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(labelsOf(r.body.stream), { labels: ['language', 'nudity', '18+'], suggested: [] }, 'confirming applies and clears the suggestion');
+ r = await call('GET', '');
+ assert.ok(!r.body.streams.some((s) => Number(s.id) === streamId), 'a newly labelled 18+ stream leaves the home list');
+ assert.strictEqual((await call('PUT', `/${streamId}`, { labels: ['gore'] })).status, 400, 'an unknown label is refused');
+ ok('the creator confirms; an unknown label is refused');
+
+ // The setup/dashboard writes the slot: the live session follows, and its suggestions are kept.
+ await db.suggestStreamContentLabels(streamId, ['flashing']);
+ r = await call('PUT', `/managed/${plain}`, { labels: ['language', 'spoilers'] });
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(labelsOf(r.body.managed_stream), { labels: ['language', 'spoilers'], suggested: [] }, 'the slot stores the creator labels');
+ live = await call('GET', `/${streamId}`);
+ assert.deepStrictEqual(labelsOf(live.body.stream), { labels: ['language', 'spoilers'], suggested: ['flashing'] }, 'the live session takes the new labels and keeps the AI suggestion');
+ assert.strictEqual((await call('PUT', `/managed/${plain}`, { labels: ['gore'] })).status, 400, 'the setup refuses an unknown label');
+ ok('the setup writes the slot and the live session follows');
+
+ // A signed-out viewer sees the same labels: nothing is hidden from anyone by the label itself.
+ const anon = await fetch(`http://127.0.0.1:${server.address().port}/api/streams/channel/labels/live`).then((x) => x.json());
+ assert.deepStrictEqual(labelsOf(anon.streams.find((s) => Number(s.id) === streamId)).labels, ['language', 'spoilers'], 'a signed-out visitor gets the chip data');
+ ok('a signed-out visitor sees the labels');
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+ console.log(`content labels: ${pass} checks — home skips 18+, suggestions stay suggestions`);
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+
+/**
+ * Labels on the go-live card (L1, with Live #477's API): a chip in the row and a fold with the six labels as toggles;
+ * the AI's suggestions are offered to confirm, never applied by themselves; the save sends the creator's labels.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ws = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'broadcast-workspace.js'), 'utf8');
+const panel = ws.slice(ws.indexOf('function _wsRenderPanel()'), ws.indexOf('\n}\n', ws.indexOf('function _wsRenderPanel()')));
+const fn = (name) => { const i = ws.indexOf(`function ${name}(`); assert.ok(i > 0, name); return ws.slice(i, ws.indexOf('\n}\n', i)); };
+
+// The chip and the fold.
+assert.ok(panel.includes(`onclick="_glOpenFold('gl-labels')"`) && panel.includes('id="gl-labels-v"'), 'a Labels chip that opens its fold');
+assert.ok(panel.includes('<details class="gl-fold" id="gl-labels">') && panel.includes('id="gl-labels-sum"'), 'a Labels fold whose line says what is set');
+assert.ok(panel.includes('Labels are choices, not bans.'), 'it says what a label does, once');
+
+// The six labels, in the contract's words; 18+ in the warning colour.
+const L = ws.slice(ws.indexOf('const _GL_LABELS = '), ws.indexOf('\n', ws.indexOf('const _GL_LABELS = ')));
+for (const id of ['nudity', 'language', 'graphic', '18+', 'flashing', 'spoilers']) assert.ok(L.includes(`['${id}',`), id);
+assert.ok(fn('_glLabelsChips').includes("aria-pressed=") && fn('_glLabelsChips').includes('gl-label--adult'), 'toggles that say their state');
+
+// The AI only suggests: its chips add a label when tapped, and nothing else moves a suggestion into the labels.
+const sug = fn('_glLabelsSuggested');
+assert.ok(sug.includes('Nothing changes until you add one.'), 'a suggestion says it changes nothing');
+assert.ok(sug.includes("onclick=\"_glToggleLabel('${id}')\""), 'a tap adds it');
+const toggle = fn('_glToggleLabel');
+assert.ok(toggle.includes('_wsMarkDirty()'), 'a change saves with the card');
+assert.ok(toggle.includes('suggested: cur.suggested.filter((l) => !labels.includes(l))'), 'a confirmed suggestion leaves the suggestions');
+
+// The save sends the creator's labels (PUT /api/streams/managed/:id { labels }).
+const save = fn('_wsSaveAll');
+assert.ok(save.includes('labels: _glLabelsOf(ms).labels,'), 'the save sends the labels');
+assert.ok(fn('_glSyncSummaries').includes("set('gl-labels-sum'"), 'the line follows the change');
+
+console.log('golive-labels: all passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+// Content labels on Live's cards and player (L1): each carries data-ov-labels for Shared's ov-labels.js (the viewer's
+// own show, blur or hide) and chips in words; the streamer's own content is never marked for them.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+const read = (f) => fs.readFileSync(path.join(__dirname, '..', f), 'utf8');
+const app = read('public/js/app.js');
+const start = app.indexOf('const OV_LABEL_NAMES = ');
+const ctx = { currentUser: null, window: {}, esc: (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])) };
+vm.createContext(ctx);
+vm.runInContext(app.slice(start) + '\n;this.h = { ovLabelsOf, ovLabelsAttr, ovLabelChips, ovIsMine };', ctx);
+const { ovLabelsOf, ovLabelsAttr, ovLabelChips } = ctx.h;
+
+const stream = { username: 'carol', content_labels: { labels: ['language', '18+', 'bogus'], suggested: ['nudity'] } };
+assert.deepStrictEqual([...ovLabelsOf(stream)], ['language', '18+'], 'only the labels in force, only known ones');
+assert.strictEqual(ovLabelsAttr(stream), ' data-ov-labels="language,18+"');
+assert.ok(ovLabelChips(stream).includes('<li class="ovk-label" data-label="language">Strong language</li>'), 'chips in words');
+assert.ok(ovLabelChips(stream).includes('ovk-label--adult'), '18+ in the warning colour');
+assert.strictEqual(ovLabelChips({ content_labels: { labels: [], suggested: ['nudity'] } }), '', 'a suggestion is not a label');
+assert.strictEqual(ovLabelsAttr({ username: 'x', content_labels: '{"labels":["graphic"],"suggested":[]}' }), ' data-ov-labels="graphic"', 'a JSON string is read too');
+ctx.currentUser = { username: 'Carol' };
+assert.strictEqual(ovLabelsAttr(stream), '', 'the streamer\'s own stream is never marked for them');
+
+const home = read('public/js/app-home.js');
+assert.ok(home.includes('<a class="stream-card"${ovLabelsAttr(s)}'), 'the home card is marked');
+assert.ok(home.includes('style="--thumb-tint:${esc(_thumbTint(s))}" data-ov-labels-media>'), 'its picture is what a blur covers');
+assert.ok(home.includes('${ovLabelChips(s)}'), 'its chips under the title');
+const player = read('public/js/stream-player.js');
+assert.ok(player.includes("ovMarkLabels(document.getElementById('video-container'), stream, { opened: true });"), 'the channel player is marked when a stream plays, as its own page (hide covers, never empties it)');
+assert.ok(/el\.toggleAttribute\('data-ov-labels-opened', Boolean\(opened && labels\.length\)\)/.test(app), 'opened is set only on a labelled thing');
+assert.ok(read('public/fragments/channel.html').includes('<div id="stream-labels" class="stream-labels"></div>'), 'the channel shows the chips under the title');
+console.log('labels-view: all passed');
@@ -16,7 +16,7 @@ assert.match(_thumbTint({ profile_color: 'red;background:url(x)', username: 'ana
@@ -16,7 +16,7 @@ assert.match(_thumbTint({ profile_color: 'red;background:url(x)', username: 'ana
assert.strictEqual(_thumbTint({ username: 'ana' }), _thumbTint({ username: 'ana' }), 'the same name, the same colour');
assert.strictEqual(_thumbTint({ username: 'ana' }), _thumbTint({ username: 'ana' }), 'the same name, the same colour');
assert.notStrictEqual(_thumbTint({ username: 'ana' }), _thumbTint({ username: 'kai' }), 'another name, another colour');
assert.notStrictEqual(_thumbTint({ username: 'ana' }), _thumbTint({ username: 'kai' }), 'another name, another colour');
assert.match(_thumbTint({}), /^hsl\(0 45% 34%\)$/);
assert.match(_thumbTint({}), /^hsl\(0 45% 34%\)$/);
-assert.match(src, /<div class="stream-card-thumb\$\{[^}]+\}" style="--thumb-tint:\$\{esc\(_thumbTint\(s\)\)\}">/, 'the card wears it');
+assert.match(src, /<div class="stream-card-thumb\$\{[^}]+\}" style="--thumb-tint:\$\{esc\(_thumbTint\(s\)\)\}"[^>]*>/, 'the card wears it');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'style.css'), 'utf8');
assert.match(css, /\.stream-card-thumb \{\s*aspect-ratio: 16\/9;[\s\S]{0,200}var\(--thumb-tint/, 'the box is drawn in it');
assert.match(css, /\.stream-card-thumb \{\s*aspect-ratio: 16\/9;[\s\S]{0,200}var\(--thumb-tint/, 'the box is drawn in it');
console.log('thumb tint: all checks passed');
console.log('thumb tint: all checks passed');