openvibers/openvibe.live Commits

Audio-only cards, content labels end to end, openvibe-shared 4.94.0 (#551)

OP OpenVibers committed 11 days ago · 025199e1e9 · 32 files, +575 −32

* Audio-only recordings say so on their cards, and their AI moments come from the transcript, not frames

* Content labels on streams: the creator sets them, the AI only suggests (L1)

- migration 0023 adds content_labels jsonb ({ labels, suggested }) to managed_streams and streams. The setup,
  dashboard and create routes take labels; a live session copies its slot's labels, and a slot change reaches the
  live session at once without dropping the session's suggestions.
- The channel page, the live list and the watch screen carry the shape through the existing stream rows.
- The AI suggets from the frame notes it already writes (nudity and 18+ from the scene, language from the
  transcript, graphic and flashing from the scene) into suggested. It never applies a label or touches content.
- The creator confirms with PUT /api/streams/:id or PUT /managed/:id; a confirmed label leaves suggested.
- The home page skips 18+: GET /api/streams, the home digest and the crawlable home all leave it out. Every other
  surface still returns it with its labels; a label never hides content from its creator.
- PUT /api/vods/:id forwards labels to Media, and PUT /api/clips/:id/labels labels a clip (the MediaHub manage forms
  call both). test/content-labels.test.js covers the suggestion rule, the home skip, confirmation and the setup.

* The go-live card sets the stream's labels; the AI's suggestions wait for the creator

* Merge remote-tracking branch 'origin/main' into refresh-476

* Merge remote-tracking branch 'origin/main' into refresh-477

# Conflicts:
#	server/streaming/routes.js

* openvibe-shared v4.93.2 (was v4.92.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v4.93.2 release.

* Content labels on Live's cards and player: each viewer's own show, blur or hide, and chips in words

* Merge feature/labels

* Merge design/golive-labels

* Merge design/labels-on-cards-and-player

* Merge pin-bump/openvibe-shared-v4.93.2

* openvibe-shared 4.94.0; the channel player is the stream's own page for labels; budgets for the five together

Changes

ddiff --git a/README.md b/README.md
iindex c2c4b402..cf8e1d20 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 8ef4d080..043576cb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0023_content_labels.sql b/migrations/0023_content_labels.sql
nnew file mode 100644
iindex 00000000..90dd75be
---- /dev/null
++++ b/migrations/0023_content_labels.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
iindex f192eafc..d1c003f2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 5af35428..63901d26 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/css/features/broadcast.css b/public/css/features/broadcast.css
iindex 96be7368..6a1fa4c2 100644
---- a/public/css/features/broadcast.css
++++ b/public/css/features/broadcast.css
@@@ -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; } }
ddiff --git a/public/css/style.css b/public/css/style.css
iindex 43dde265..97fc48e3 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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; transf
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); }
}
++
++/* 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; }
ddiff --git a/public/fragments/channel.html b/public/fragments/channel.html
iindex 1546db5f..4fc09799 100644
---- a/public/fragments/channel.html
++++ b/public/fragments/channel.html
@@@ -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>
ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 101be8fe..260c0adf 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -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>
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex edd47c85..c8b5400f 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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')}
ddiff --git a/public/js/app.js b/public/js/app.js
iindex f4023115..48bc92a4 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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);
++}
ddiff --git a/public/js/broadcast-workspace.js b/public/js/broadcast-workspace.js
iindex a898112c..17a633db 100644
---- a/public/js/broadcast-workspace.js
++++ b/public/js/broadcast-workspace.js
@@@ -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 = {
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex a7e6c1dd..ed07a093 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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 playing
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();
_applyLoaderThumb(stream);
const proto = stream.protocol || 'jsmpeg';
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex c2174b97..a1250c49 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -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
htmlBrotliKB: 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)
};
ddiff --git a/server/ai/ai-analysis.js b/server/ai/ai-analysis.js
iindex 66f0806f..9c9cd5bb 100644
---- a/server/ai/ai-analysis.js
++++ b/server/ai/ai-analysis.js
@@@ -199,7 +199,7 @@ async function _generateVodOverviewInner(vod) {
if (!src) { try { await db.setVodAiOverview(vod.id, ' '); } catch { /* */ } return null; } // unprocessable — mark done
const 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 say
try { 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 moments
if (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 : '';
ddiff --git a/server/ai/media-analysis.js b/server/ai/media-analysis.js
iindex 3ca6907c..391ece3c 100644
---- a/server/ai/media-analysis.js
++++ b/server/ai/media-analysis.js
@@@ -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;
ddiff --git a/server/ai/stream-memory-job.js b/server/ai/stream-memory-job.js
iindex d741045c..abd828e9 100644
---- a/server/ai/stream-memory-job.js
++++ b/server/ai/stream-memory-job.js
@@@ -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
ddiff --git a/server/content/feed.js b/server/content/feed.js
iindex 7605b55a..0e6bd9c8 100644
---- a/server/content/feed.js
++++ b/server/content/feed.js
@@@ -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,
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 2d54ad96..849f3751 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 7e17fb84..8c4f5c8e 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -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_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
++ 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,
ddiff --git a/server/labels/index.js b/server/labels/index.js
nnew file mode 100644
iindex 00000000..e3cda25c
---- /dev/null
++++ b/server/labels/index.js
@@@ -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 };
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 21eed0ea..5a8118c3 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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;
ddiff --git a/server/media-proxy/vods.js b/server/media-proxy/vods.js
iindex 29d2dec2..091d047d 100644
---- a/server/media-proxy/vods.js
++++ b/server/media-proxy/vods.js
@@@ -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) {
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex 7c9f3282..e969f68c 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -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;
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex ace30162..892e6bf1 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -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 stream
const 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);
ddiff --git a/server/web/serializers.js b/server/web/serializers.js
iindex bbb8ec27..b6488799 100644
---- a/server/web/serializers.js
++++ b/server/web/serializers.js
@@@ -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'];
ddiff --git a/test/audio-only-chip.test.js b/test/audio-only-chip.test.js
nnew file mode 100644
iindex 00000000..508b29b9
---- /dev/null
++++ b/test/audio-only-chip.test.js
@@@ -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');
ddiff --git a/test/content-feed.test.js b/test/content-feed.test.js
iindex b441b773..c11bb90d 100644
---- a/test/content-feed.test.js
++++ b/test/content-feed.test.js
@@@ -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');
ddiff --git a/test/content-labels.test.js b/test/content-labels.test.js
nnew file mode 100644
iindex 00000000..c773d956
---- /dev/null
++++ b/test/content-labels.test.js
@@@ -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); });
ddiff --git a/test/golive-labels.test.js b/test/golive-labels.test.js
nnew file mode 100644
iindex 00000000..2388bb01
---- /dev/null
++++ b/test/golive-labels.test.js
@@@ -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');
ddiff --git a/test/labels-view.test.js b/test/labels-view.test.js
nnew file mode 100644
iindex 00000000..712136d3
---- /dev/null
++++ b/test/labels-view.test.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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');
ddiff --git a/test/thumb-tint.test.js b/test/thumb-tint.test.js
iindex 3642c83c..2546217b 100644
---- a/test/thumb-tint.test.js
++++ b/test/thumb-tint.test.js
@@@ -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.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');