openvibers/openvibe.live Commits
No text arrows: drawn glyphs (Design rule 169) (#880)
Changes
@@@ -66,7 +66,8 @@.go:hover { transform: translateY(-2px) scale(1.03); }.go:active { transform: scale(0.97); }.go:disabled { cursor: default; animation: none; opacity: 0.85; }-- .go .arrow { display: inline-block; transition: transform var(--ov-dur-2, 0.2s); }++ .go .arrow { display: inline-block; margin-left: 5px; vertical-align: -0.18em; transition: transform var(--ov-dur-2, 0.2s); }++ .go .arrow svg { display: block; width: 15px; height: 15px; }.go:hover .arrow { transform: translateX(4px); }.go .rip { position: absolute; border-radius: 50%; background: rgba(255,255,255,0.5); transform: scale(0); animation: rip var(--ov-dur-4, 0.7s) var(--ov-ease-out, ease-out) forwards; pointer-events: none; }@keyframes sheen { to { background-position: -200% 0; } }@@@ -126,7 +127,7 @@<div class="sorry rv" style="--d:8.2s"><h3 class="sorry-head">Sorry I'm being a bitch about this whole thing.</h3><p class="sorry-body">I don't actually want to lock you out. Press continue and the ban is gone — on every network and every device. Just be decent to me.</p>-- <button class="go" id="go" type="button">♥ Continue <span class="arrow">→</span></button>++ <button class="go" id="go" type="button">♥ Continue<span class="arrow"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3"/></svg></span></button><span class="go-hint">One press. That's it.</span></div><p class="fine rv" style="--d:8.6s"><span class="logo">OpenVibe.Live</span> · you will see this page every time you open the site, on every device and every network you use — until you press continue.</p>@@@ -136,6 +137,7 @@// Continue: lift the ban server-side, then play the lift and go home.(function () {var btn = document.getElementById('go');++ var ARROW = '<span class="arrow"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3"/></svg></span>';if (!btn) return;btn.addEventListener('click', async function (ev) {if (btn.disabled) return;@@@ -148,7 +150,7 @@var j = await res.json();ok = !!(res.ok && j.ok); name = j.name || null;} catch (e) { ok = false; }-- if (!ok) { btn.disabled = false; btn.innerHTML = 'Try again <span class="arrow">→</span>'; return; }++ if (!ok) { btn.disabled = false; btn.innerHTML = 'Try again' + ARROW; return; }var reduced = (document.documentElement.getAttribute('data-ov-motion') === 'off');if (name) document.getElementById('welcome-line').textContent = name + ', the ban is lifted. Taking you to the site…';if (!reduced) {@@@ -152,7 +152,7 @@</summary><div class="bc-app-guide-body"><ol>-- <li>Open OBS → Settings → Stream</li>++ <li>Open OBS, then Settings, then Stream</li><li>Set Service to <strong>Custom</strong></li><li>Paste the <strong>Server URL</strong> above</li><li>Paste the <strong>Stream Key</strong></li>@@@ -202,8 +202,8 @@<h4 style="margin:12px 0 8px">IRL Pro Step-by-Step</h4><ol>-- <li>Open <strong>IRL Pro</strong> → <strong>⚙️ Settings</strong> → <strong>Streaming</strong></li>-- <li>Select <strong>Platform</strong> → <strong>Custom RTMP</strong></li>++ <li>Open <strong>IRL Pro</strong>, then <strong>⚙️ Settings</strong>, then <strong>Streaming</strong></li>++ <li>Select <strong>Platform</strong>, then <strong>Custom RTMP</strong></li><li>Paste the <strong>Server URL</strong> and <strong>Stream Key</strong> from Option A above</li><li><strong>Leave Username and Password empty</strong></li><li>Set <strong>Resolution</strong> to 720p, <strong>Bitrate</strong> to 2500–4000 kbps</li>@@@ -236,14 +236,14 @@<div class="bc-app-guide-body"><p class="bc-app-guide-desc">Stream directly from a terminal — great for headless servers, Raspberry Pi, 3D printer cams, and automated pipelines.</p><div class="bc-code-block" style="margin-bottom:12px">-- <label><i class="fa-solid fa-video"></i> Webcam + Audio → RTMP</label>++ <label><i class="fa-solid fa-video"></i> Webcam + Audio to RTMP</label><div class="bc-code-copy"><code id="bc-rtmp-ffmpeg-cam" style="white-space:pre-wrap;word-break:break-all">loading...</code><button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-rtmp-ffmpeg-cam').textContent)"><i class="fa-solid fa-copy"></i></button></div></div><div class="bc-code-block" style="margin-bottom:12px">-- <label><i class="fa-solid fa-display"></i> Screen Capture → RTMP</label>++ <label><i class="fa-solid fa-display"></i> Screen Capture to RTMP</label><div class="bc-code-copy"><code id="bc-rtmp-ffmpeg-screen" style="white-space:pre-wrap;word-break:break-all">loading...</code><button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-rtmp-ffmpeg-screen').textContent)"><i class="fa-solid fa-copy"></i></button>@@@ -314,7 +314,7 @@</div></div><div class="bc-code-block">-- <label><i class="fa-solid fa-display"></i> OBS Virtual Camera → JSMPEG</label>++ <label><i class="fa-solid fa-display"></i> OBS Virtual Camera to JSMPEG</label><div class="bc-code-copy"><code id="bc-jsmpeg-cmd-obs" style="white-space:pre-wrap;word-break:break-all">loading...</code><button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-jsmpeg-cmd-obs').textContent)"><i class="fa-solid fa-copy"></i></button>@@@ -403,7 +403,7 @@<div class="bc-code-block" style="margin-bottom:8px"><label>Use PulseAudio instead of ALSA</label><div class="bc-code-copy">-- <code style="white-space:pre-wrap">Replace: -f alsa -i default → -f pulse -i default</code>++ <code style="white-space:pre-wrap">Replace: -f alsa -i default with -f pulse -i default</code><button class="btn btn-small" onclick="copyToClipboard('-f pulse -i default')"><i class="fa-solid fa-copy"></i></button></div></div>@@@ -411,7 +411,7 @@<div class="bc-code-block" style="margin-bottom:8px"><label>OBS Virtual Camera</label><div class="bc-code-copy">-- <code style="white-space:pre-wrap">Start OBS → Tools → Start Virtual Camera → use /dev/video2 (or check v4l2-ctl --list-devices)</code>++ <code style="white-space:pre-wrap">Start OBS, then Tools, then Start Virtual Camera, then use /dev/video2 (or check v4l2-ctl --list-devices)</code><button class="btn btn-small" onclick="copyToClipboard('v4l2-ctl --list-devices')"><i class="fa-solid fa-copy"></i></button></div></div>@@@ -481,7 +481,7 @@</div></div><div class="bc-code-block" style="margin-bottom:12px">-- <label>IP camera (RTSP) → JSMPEG</label>++ <label>IP camera (RTSP) to JSMPEG</label><div class="bc-code-copy"><code id="bc-jsmpeg-cmd-ipcam" style="white-space:pre-wrap;word-break:break-all">loading...</code><button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-jsmpeg-cmd-ipcam').textContent)"><i class="fa-solid fa-copy"></i></button>@@@ -550,7 +550,7 @@ WantedBy=multi-user.target</code><h2><i class="fa-solid fa-desktop"></i> WebRTC via OBS (WHIP)</h2><p class="bc-instructions-intro">Your stream is created! Paste the info below into OBS Studio 30+ to start streaming with ultra-low latency via WebRTC. This is newer and faster than RTMP.</p>-- <p class="muted" style="margin-top:8px"><i class="fa-solid fa-info-circle"></i> Note: on some Linux distributions, the system OBS package may disable WHIP even in OBS 30+. If you do not see the WHIP service in Settings → Stream, install OBS from Flatpak or AppImage instead of the distro package.</p>++ <p class="muted" style="margin-top:8px"><i class="fa-solid fa-info-circle"></i> Note: on some Linux distributions, the system OBS package may disable WHIP even in OBS 30+. If you do not see the WHIP service in Settings, then Stream, install OBS from Flatpak or AppImage instead of the distro package.</p><div class="bc-code-block"><label>WHIP Endpoint</label><div class="bc-code-copy">@@@ -570,7 +570,7 @@ WantedBy=multi-user.target</code><div class="bc-instructions-steps"><h3>OBS 30+ Setup</h3><ol>-- <li>Open OBS → Settings → Stream</li>++ <li>Open OBS, then Settings, then Stream</li><li>Set Service to <strong>WHIP</strong></li><li>Paste the <strong>WHIP Endpoint</strong> URL above</li><li>Paste the <strong>Bearer Token</strong> as the auth key</li>@@@ -366,7 +366,7 @@<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) --><div class="dash-card dash-card-wide" id="dash-card-aimoments"><h3 style="margin:0 0 6px"><i class="fa-solid fa-sparkles"></i> AI Moments of My Stream</h3>-- <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home → AI Moments from my streams.</p>++ <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home, then AI Moments from my streams.</p><div id="dash-ai-clips" class="vod-grid"></div></div>@@@ -504,7 +504,7 @@<input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)"><span>Let OpenVibe's AI make Moments from my streams</span></label>-- <p class="muted" style="font-size:0.85rem;margin:0">Moments are auto-clips cut when chat reacts, frames the AI picks and posts as notes, and AI-written after-show recaps. They are always labelled as AI-made, never credited to you, and kept out of search. Turned off, the AI makes no new ones from your streams (your after-show report keeps its stats). Moments already made stay until you delete them under Content → AI Moments.</p>++ <p class="muted" style="font-size:0.85rem;margin:0">Moments are auto-clips cut when chat reacts, frames the AI picks and posts as notes, and AI-written after-show recaps. They are always labelled as AI-made, never credited to you, and kept out of search. Turned off, the AI makes no new ones from your streams (your after-show report keeps its stats). Moments already made stay until you delete them under Content, then AI Moments.</p><span id="dash-ai-derivation-status" class="muted" style="font-size:0.82rem"></span></div>@@@ -96,7 +96,7 @@<h3>Base URL & Auth</h3><p>Base URL: <code>https://openvibe.live</code> (replace with your instance).</p><p>Auth: <code>Authorization: Bearer <JWT></code> header. On WebSockets, the header on the upgrade request or the <code>token</code> of the first <code>join</code> message. A token in the URL or a cookie does not sign a socket in.</p>--<p>API tokens: <code>hbt_</code> prefix, scoped permissions, created from Dashboard → API Tokens.</p>++<p>API tokens: <code>hbt_</code> prefix, scoped permissions, created from Dashboard, then API Tokens.</p></div><!-- ═══════════════════════════════════════════════════════════ -->@@@ -145,9 +145,9 @@<h3>Admin Proxy Architecture</h3><p>openvibe.network admin panel proxies requests to OpenVibe.Live:</p>--<pre><code>openvibe.network /api/admin/streamer/* → OpenVibe.Live /api/admin/*--openvibe.network /api/admin/streamer-tts/* → OpenVibe.Live /api/tts/*--openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code></pre>++<pre><code>openvibe.network /api/admin/streamer/* maps to OpenVibe.Live /api/admin/*++openvibe.network /api/admin/streamer-tts/* maps to OpenVibe.Live /api/tts/*++openvibe.network /api/admin/streamer-mod/* maps to OpenVibe.Live /api/mod/*</code></pre><p>Server-to-server calls carry a short-lived service token from openvibe.network, scoped to the one capability each route performs.</p></div>@@@ -171,8 +171,8 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code><table class="doc-table" tabindex="0"><thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead><tbody>--<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 → openvibe.network OAuth authorize</td></tr>--<tr><td>GET</td><td>/api/auth/callback?code&state</td><td>No</td><td>OAuth code→token exchange (PKCE); sets cookies</td></tr>++<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 redirects to openvibe.network OAuth authorize</td></tr>++<tr><td>GET</td><td>/api/auth/callback?code&state</td><td>No</td><td>OAuth code to token exchange (PKCE); sets cookies</td></tr><tr><td>GET</td><td>/api/auth/health</td><td>No</td><td>OAuth client health; 503 only when the Network refuses this site's client</td></tr><tr><td>POST</td><td>/api/auth/refresh</td><td>Cookie</td><td>Refresh access token via httpOnly <code>ov_refresh</code> cookie</td></tr><tr><td>POST</td><td>/api/auth/logout</td><td>No</td><td>Clear auth cookies</td></tr>@@@ -189,11 +189,11 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code></table><h3>API Tokens (<code>hbt_</code> prefix)</h3>--<p>Long-lived, scoped tokens for bots/integrations. Create them from Dashboard → API Tokens (max 10 active). The token string is shown once. Use as <code>Authorization: Bearer hbt_xxxxxxxxxxxx</code>.</p>++<p>Long-lived, scoped tokens for bots/integrations. Create them from Dashboard, then API Tokens (max 10 active). The token string is shown once. Use as <code>Authorization: Bearer hbt_xxxxxxxxxxxx</code>.</p><table class="doc-table" tabindex="0"><thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead><tbody>--<tr><td>POST</td><td>/api/auth/tokens</td><td>Create <code>{ label, scopes[], expiresAt? }</code> → <code>{ id, token, ... }</code> (JWT auth only, not an hbt_ token)</td></tr>++<tr><td>POST</td><td>/api/auth/tokens</td><td>Create <code>{ label, scopes[], expiresAt? }</code> returns <code>{ id, token, ... }</code> (JWT auth only, not an hbt_ token)</td></tr><tr><td>GET</td><td>/api/auth/tokens</td><td>List tokens + <code>valid_scopes</code>, <code>scope_definitions</code>, <code>token_presets</code></td></tr><tr><td>DELETE</td><td>/api/auth/tokens/:id</td><td>Revoke a token</td></tr></tbody>@@@ -201,7 +201,7 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code><p><strong>Scopes:</strong> <code>chat</code>, <code>read</code>, <code>stream</code>, <code>control</code>, <code>vibe_coding_publish</code>. <strong>Presets:</strong> chat-bot (<code>chat,read</code>), stream-controller (<code>read,stream,control</code>), vibe-coding-publisher (<code>read,vibe_coding_publish</code>).</p><h3>Example: use an API token</h3>--<pre><code>// Create the token once from Dashboard → API Tokens (scopes: chat, read),++<pre><code>// Create the token once from Dashboard, then API Tokens (scopes: chat, read),// then use it directly — no login flow needed.const TOKEN = 'hbt_xxxxxxxxxxxxxxxx';@@@ -226,7 +226,7 @@ console.log('Acting as:', me.user.username);<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead><tbody><tr><td>GET</td><td>/api/streams/managed</td><td>Yes</td><td>My slots + slot limit</td></tr>--<tr><td>POST</td><td>/api/streams/managed</td><td>Yes</td><td>Create a slot → unique <code>stream_key</code>. Body: <code>title, description, category, protocol, is_nsfw, slug, streaming_method</code></td></tr>++<tr><td>POST</td><td>/api/streams/managed</td><td>Yes</td><td>Create a slot; it returns a unique <code>stream_key</code>. Body: <code>title, description, category, protocol, is_nsfw, slug, streaming_method</code></td></tr><tr><td>PUT</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Update slot + slot settings (see below)</td></tr><tr><td>DELETE</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Delete slot (409 if live)</td></tr><tr><td>POST</td><td>/api/streams/managed/:id/regenerate-key</td><td>Owner</td><td>New slot stream key</td></tr>@@@ -238,7 +238,7 @@ console.log('Acting as:', me.user.username);<table class="doc-table" tabindex="0"><thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead><tbody>--<tr><td>POST</td><td>/api/streams</td><td>Yes</td><td><strong>Go live</strong> from a slot. Body: <code>managed_stream_id</code> (required) + optional <code>title, category, tags, is_nsfw</code> → <code>{ stream, endpoint }</code></td></tr>++<tr><td>POST</td><td>/api/streams</td><td>Yes</td><td><strong>Go live</strong> from a slot. Body: <code>managed_stream_id</code> (required) + optional <code>title, category, tags, is_nsfw</code> returns <code>{ stream, endpoint }</code></td></tr><tr><td>GET</td><td>/api/streams/:id/endpoint</td><td>Owner</td><td>Ingest endpoint + <code>stream_key</code> for the session's protocol</td></tr><tr><td>PUT</td><td>/api/streams/:id</td><td>Owner</td><td>Update live session (title/description/category/tags)</td></tr><tr><td>DELETE</td><td>/api/streams/:id</td><td>Owner</td><td>End the session</td></tr>@@@ -325,32 +325,32 @@ const { answer, location } = await live.whip.publish(live.whip.url(slot.stream_k<table class="doc-table" tabindex="0"><thead><tr><th>Type</th><th>Dir</th><th>Key Fields</th><th>Description</th></tr></thead><tbody>--<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr>--<tr><td>watch</td><td>C→S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>--<tr><td>broadcaster-ready</td><td>S→Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>--<tr><td>watch-queued</td><td>S→Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>--<tr><td>sfu-viewer-ready</td><td>S→Viewer</td><td>rtpCapabilities, producers</td><td>Server has live SFU producers ready for consumption</td></tr>--<tr><td>sfu-viewer-create-transport</td><td>C→S</td><td>—</td><td>Viewer asks the server to create an SFU recv transport</td></tr>--<tr><td>sfu-viewer-transport-created</td><td>S→Viewer</td><td>id, iceParameters, iceCandidates, dtlsParameters, iceServers</td><td>Viewer recv transport parameters</td></tr>--<tr><td>sfu-viewer-connect-transport</td><td>C→S</td><td>transportId, dtlsParameters</td><td>Connect the viewer recv transport</td></tr>--<tr><td>sfu-viewer-transport-connected</td><td>S→Viewer</td><td>transportId</td><td>Viewer recv transport connected</td></tr>--<tr><td>sfu-viewer-consume</td><td>C→S</td><td>transportId, producerId, rtpCapabilities</td><td>Create an SFU consumer for a live producer</td></tr>--<tr><td>sfu-viewer-consumed</td><td>S→Viewer</td><td>id, producerId, kind, rtpParameters</td><td>Consumer parameters returned to mediasoup-client</td></tr>--<tr><td>sfu-source-unavailable</td><td>S→Viewer</td><td>reason</td><td>Live source exists, but it is not currently healthy for viewers</td></tr>--<tr><td>sfu-get-capabilities</td><td>C→S</td><td>—</td><td>Get Mediasoup RTP capabilities</td></tr>--<tr><td>sfu-create-transport</td><td>C→S</td><td>direction: send|recv</td><td>Create WebRTC transport</td></tr>--<tr><td>sfu-connect-transport</td><td>C→S</td><td>transportId, dtlsParameters</td><td>Connect DTLS</td></tr>--<tr><td>sfu-produce</td><td>C→S</td><td>transportId, kind, rtpParameters</td><td>Start producing audio/video</td></tr>--<tr><td>sfu-stop-produce</td><td>C→S</td><td>producerId</td><td>Stop a producer</td></tr>--<tr><td>viewer-count</td><td>S→All</td><td>count</td><td>Updated viewer count broadcast</td></tr>--<tr><td>stream-ended</td><td>S→Viewers</td><td>—</td><td>Stream has ended</td></tr>++<tr><td>welcome</td><td>S to C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr>++<tr><td>watch</td><td>C to S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>++<tr><td>broadcaster-ready</td><td>S to Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>++<tr><td>watch-queued</td><td>S to Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>++<tr><td>sfu-viewer-ready</td><td>S to Viewer</td><td>rtpCapabilities, producers</td><td>Server has live SFU producers ready for consumption</td></tr>++<tr><td>sfu-viewer-create-transport</td><td>C to S</td><td>—</td><td>Viewer asks the server to create an SFU recv transport</td></tr>++<tr><td>sfu-viewer-transport-created</td><td>S to Viewer</td><td>id, iceParameters, iceCandidates, dtlsParameters, iceServers</td><td>Viewer recv transport parameters</td></tr>++<tr><td>sfu-viewer-connect-transport</td><td>C to S</td><td>transportId, dtlsParameters</td><td>Connect the viewer recv transport</td></tr>++<tr><td>sfu-viewer-transport-connected</td><td>S to Viewer</td><td>transportId</td><td>Viewer recv transport connected</td></tr>++<tr><td>sfu-viewer-consume</td><td>C to S</td><td>transportId, producerId, rtpCapabilities</td><td>Create an SFU consumer for a live producer</td></tr>++<tr><td>sfu-viewer-consumed</td><td>S to Viewer</td><td>id, producerId, kind, rtpParameters</td><td>Consumer parameters returned to mediasoup-client</td></tr>++<tr><td>sfu-source-unavailable</td><td>S to Viewer</td><td>reason</td><td>Live source exists, but it is not currently healthy for viewers</td></tr>++<tr><td>sfu-get-capabilities</td><td>C to S</td><td>—</td><td>Get Mediasoup RTP capabilities</td></tr>++<tr><td>sfu-create-transport</td><td>C to S</td><td>direction: send|recv</td><td>Create WebRTC transport</td></tr>++<tr><td>sfu-connect-transport</td><td>C to S</td><td>transportId, dtlsParameters</td><td>Connect DTLS</td></tr>++<tr><td>sfu-produce</td><td>C to S</td><td>transportId, kind, rtpParameters</td><td>Start producing audio/video</td></tr>++<tr><td>sfu-stop-produce</td><td>C to S</td><td>producerId</td><td>Stop a producer</td></tr>++<tr><td>viewer-count</td><td>S to All</td><td>count</td><td>Updated viewer count broadcast</td></tr>++<tr><td>stream-ended</td><td>S to Viewers</td><td>—</td><td>Stream has ended</td></tr></tbody></table><p class="muted" style="margin-top:10px">Viewer media is delivered exclusively through the Mediasoup SFU. There is no direct peer-to-peer relay: legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> messages are not supported.</p><h3>RTMP Ingest (OBS)</h3><pre><code>Server: rtmp://openvibe.live/live--Key: YOUR_STREAM_KEY (get from Dashboard → Stream Key)++Key: YOUR_STREAM_KEY (get from Dashboard, then Stream Key)# Playback proxy for browserGET /api/streams/rtmp-proxy/:streamId.flv@@@ -368,7 +368,7 @@ GET /api/streams/rtmp-proxy/:streamId.flv<p>Connect: <code>wss://openvibe.live/ws/chat?stream=STREAM_ID</code> with an <code>Authorization: Bearer <token></code> header (a JWT or an <code>hbt_</code> API token), then send <code>{ "type": "join", "streamId": STREAM_ID }</code>. A client that cannot set headers (a browser) puts the token in that join instead: <code>{ "type": "join", "streamId": STREAM_ID, "token": "…" }</code>.</p><p>Omit <code>stream</code> for global chat; omit the token to connect anonymously (assigned an anonXXXXX identity). A token in the URL does not sign the socket in: URLs end up in logs.</p>--<h3>Client → Server Messages</h3>++<h3>Client to Server Messages</h3><table class="doc-table" tabindex="0"><thead><tr><th>Type</th><th>Payload</th><th>Description</th></tr></thead><tbody>@@@ -380,7 +380,7 @@ GET /api/streams/rtmp-proxy/:streamId.flv</tbody></table>--<h3>Server → Client Messages</h3>++<h3>Server to Client Messages</h3><table class="doc-table" tabindex="0"><thead><tr><th>Type</th><th>Key Fields</th><th>Description</th></tr></thead><tbody>@@@ -526,8 +526,8 @@ while True:<tbody><tr><td>GET</td><td>/api/funds/balance</td><td>Yes</td><td>Both balances + USD cashout values (<code>usd_value = cashout_balance / 100</code>)</td></tr><tr><td>POST</td><td>/api/funds/donate</td><td>Yes</td><td>Donate spendable bucks to a streamer; credits their cashout balance + advances a goal. Body: <code>amount, streamer_id|stream_id, message?, goal_id?</code></td></tr>--<tr><td>POST</td><td>/api/funds/cashout</td><td>Yes</td><td>Request cashout → escrow. Body: <code>amount</code> (≥ 500), <code>paypal_email</code></td></tr>--<tr><td>POST</td><td>/api/funds/recycle</td><td>Yes</td><td>Move cashout balance → spendable (re-donate instead of cashing out)</td></tr>++<tr><td>POST</td><td>/api/funds/cashout</td><td>Yes</td><td>Request cashout to escrow. Body: <code>amount</code> (≥ 500), <code>paypal_email</code></td></tr>++<tr><td>POST</td><td>/api/funds/recycle</td><td>Yes</td><td>Move cashout balance to spendable (re-donate instead of cashing out)</td></tr><tr><td>GET</td><td>/api/funds/history</td><td>Yes</td><td>Transaction history (<code>?limit</code> ≤ 200)</td></tr><tr><td>GET</td><td>/api/funds/leaderboard/:streamId</td><td>No</td><td>Top donors for a stream</td></tr><tr><td>GET</td><td>/api/funds/goals/:userId</td><td>No</td><td>Public goal widget set (active + recently reached)</td></tr>@@@ -544,14 +544,14 @@ while True:<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead><tbody><tr><td>GET</td><td>/api/payments/config</td><td>No</td><td>Live providers + pricing: <code>packages</code>, <code>cashoutBucksPerUsd</code> (100), <code>minPurchaseBucks</code>, <code>subPriceUsd</code></td></tr>--<tr><td>POST</td><td>/api/payments/bucks/checkout</td><td>Yes</td><td>Buy bucks (USD derived from volume tiers). Body: <code>provider, bucks</code> → <code>{ url }</code> hosted checkout</td></tr>++<tr><td>POST</td><td>/api/payments/bucks/checkout</td><td>Yes</td><td>Buy bucks (USD derived from volume tiers). Body: <code>provider, bucks</code> returns <code>{ url }</code> hosted checkout</td></tr><tr><td>POST</td><td>/api/payments/subscribe</td><td>Yes</td><td>Subscribe to a channel. Body: <code>provider</code> (<code>stripe</code>|<code>bucks</code>), <code>streamer</code></td></tr><tr><td>GET</td><td>/api/payments/subscriptions/mine · /channel/:username</td><td>Yes/Opt</td><td>My subs / am-I-subscribed + count + price</td></tr><tr><td>POST</td><td>/api/payments/subscriptions/:id/cancel</td><td>Yes</td><td>Cancel (Stripe at period end; bucks immediate)</td></tr><tr><td>POST/ALL</td><td>/api/payments/webhook/{stripe,paypal,ccbill,crypto}</td><td>No</td><td><code>410 Gone</code> with <code>webhook_url</code>: providers deliver to OpenVibe.Billing (<code>https://billing.openvibe.network/webhooks/{stripe,paypal,ccbill,nowpayments}</code>)</td></tr></tbody></table>--<p><strong>Packages</strong>: <code>100, 500, 1000, 2500, 5000, 10000, 25000</code> bucks. <strong>Per-buck price tiers</strong>: ≥25000→$0.0110, ≥10000→$0.0115, ≥5000→$0.0120, ≥2500→$0.0124, ≥1000→$0.0130, ≥500→$0.0140, else $0.0150. Streamers keep <code>sub_streamer_share_pct</code> (default 70%) of sub value into their cashout balance.</p>++<p><strong>Packages</strong>: <code>100, 500, 1000, 2500, 5000, 10000, 25000</code> bucks. <strong>Per-buck price tiers</strong>: ≥25000: $0.0110, ≥10000: $0.0115, ≥5000: $0.0120, ≥2500: $0.0124, ≥1000: $0.0130, ≥500: $0.0140, else $0.0150. Streamers keep <code>sub_streamer_share_pct</code> (default 70%) of sub value into their cashout balance.</p><h3>OpenCoins / Coins — <code>/api/coins</code></h3><p>Free currencies. <strong>GOLD</strong> is a global wallet (games, cosmetics, media requests). <strong>Channel points ("OpenCoins")</strong> are per-streamer loyalty points earned by watching/chatting a channel and spent on that streamer's rewards. Defaults: watch = 10/5min, chat = 5 (max 1/min), follow = 50 once; not earnable on your own stream.</p>@@@ -559,7 +559,7 @@ while True:<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead><tbody><tr><td>GET</td><td>/api/coins/balance · /channel-balance</td><td>Yes</td><td>Global GOLD / per-streamer points (<code>?streamerId</code>)</td></tr>--<tr><td>POST</td><td>/api/coins/heartbeat · /bonus</td><td>Yes</td><td>Watch-time tick / claim bonus game → earn points (<code>streamId</code>)</td></tr>++<tr><td>POST</td><td>/api/coins/heartbeat · /bonus</td><td>Yes</td><td>Watch-time tick / claim bonus game, then earn points (<code>streamId</code>)</td></tr><tr><td>GET</td><td>/api/coins/rewards/:userId · /config/:userId</td><td>No</td><td>A streamer's rewards + points config</td></tr><tr><td>PUT</td><td>/api/coins/config</td><td>Yes</td><td>Configure my channel points (name, icon, watch/game rates)</td></tr><tr><td>POST / PUT / DELETE</td><td>/api/coins/rewards[/:id]</td><td>Yes</td><td>Manage my rewards (<code>title, cost, cooldown_seconds, max_per_stream, requires_input</code>)</td></tr>@@@ -878,7 +878,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre><table class="doc-table" tabindex="0"><thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead><tbody>--<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies, newest first (<code>type</code> = <code>vod|clip</code>, <code>?limit,offset</code>) → <code>{ comments, total, thread? }</code>; <code>thread.url</code> is the same thread on OpenVibe.Community (public and unlisted items)</td></tr>++<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies, newest first (<code>type</code> = <code>vod|clip</code>, <code>?limit,offset</code>) returns <code>{ comments, total, thread? }</code>; <code>thread.url</code> is the same thread on OpenVibe.Community (public and unlisted items)</td></tr><tr><td>GET</td><td>/api/comments/:commentId/replies</td><td>Opt</td><td>All replies of a comment</td></tr><tr><td>POST</td><td>/api/comments/:type/:id</td><td>Yes</td><td>Add a comment/reply (<code>message</code>, <code>parent_id?</code>)</td></tr><tr><td>PUT</td><td>/api/comments/:commentId</td><td>Author</td><td>Edit your own comment</td></tr>@@@ -893,7 +893,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre><tbody><tr><td>GET</td><td>/api/pastes</td><td>Opt</td><td>List public pastes (<code>?limit,offset,type,username,search,sort</code>)</td></tr><tr><td>GET</td><td>/api/pastes/:slug · /:slug/raw</td><td>Opt/No</td><td>Get a paste / raw content</td></tr>--<tr><td>POST</td><td>/api/pastes</td><td>Opt</td><td>Create text paste (<code>content</code> + <code>title, language, visibility</code>); anon allowed if enabled → <code>{ paste, url }</code></td></tr>++<tr><td>POST</td><td>/api/pastes</td><td>Opt</td><td>Create text paste (<code>content</code> + <code>title, language, visibility</code>); anon allowed if enabled returns <code>{ paste, url }</code></td></tr><tr><td>POST</td><td>/api/pastes/screenshot</td><td>Opt</td><td>Upload an image paste</td></tr><tr><td>PUT / DELETE</td><td>/api/pastes/:slug</td><td>Owner/staff</td><td>Edit / delete a paste</td></tr></tbody>@@@ -79,7 +79,7 @@<span title="Live transcript"><i class="fa-solid fa-ear-listen"></i> heard ${st.senses.heard_lines ? `${st.senses.heard_lines} lines, latest ${ago(st.senses.heard_age_ms)}` : (st.timeline_enabled ? 'nothing recent' : 'off')}</span><span title="Latest screen analysis"><i class="fa-solid fa-eye"></i> seen ${st.senses.seen_age_ms != null ? ago(st.senses.seen_age_ms) : 'never'}${st.senses.seen_text ? ` — ${esc(st.senses.seen_text.slice(0, 90))}` : ''}</span><span title="Cached prompt prefix"><i class="fa-solid fa-bolt"></i> context ${st.senses.stable_prefix_chars ? `${Math.round(st.senses.stable_prefix_chars / 4)} tokens cached` : 'building'}</span>-- <span><i class="fa-solid fa-clock"></i> next pass ${st.next_tick_in_ms != null ? `in ${Math.round(st.next_tick_in_ms / 1000)}s` : '—'} · ${st.lines_last_minute}/min · bot↔bot ${Math.round((st.bot_share || 0) * 100)}%</span>`;++ <span><i class="fa-solid fa-clock"></i> next pass ${st.next_tick_in_ms != null ? `in ${Math.round(st.next_tick_in_ms / 1000)}s` : '—'} · ${st.lines_last_minute}/min · bot-to-bot ${Math.round((st.bot_share || 0) * 100)}%</span>`;}if (b) _setUsageMeter(b, st);}@@@ -162,7 +162,7 @@function logRow(r) {const icon = { line: 'fa-comment', tick: 'fa-clapperboard', skip: 'fa-forward', mention: 'fa-bullhorn', fold: 'fa-brain', vision: 'fa-eye', degrade: 'fa-gauge', pause: 'fa-pause', error: 'fa-triangle-exclamation', info: 'fa-circle-info' }[r.event] || 'fa-circle';const cost = r.cost_usd != null ? `<span class="muted" title="${r.tokens_in || 0} in (${r.tokens_cached || 0} cached) / ${r.tokens_out || 0} out · ${esc(r.model || '')}">${money(r.cost_usd)}${r.tokens_in ? ` · ${Math.round(((r.tokens_cached || 0) / r.tokens_in) * 100)}% cached` : ''}</span>` : '';-- const who = r.bot_username ? `<b>${esc(r.bot_username)}</b>${r.target ? ` <span class="muted">→ ${esc(r.target)}</span>` : ''}` : `<span class="muted">${esc(r.event)}</span>`;++ const who = r.bot_username ? `<b>${esc(r.bot_username)}</b>${r.target ? ` <span class="muted">to ${esc(r.target)}</span>` : ''}` : `<span class="muted">${esc(r.event)}</span>`;return `<div class="aiv-log-row aiv-log-${esc(r.event)}"><i class="fa-solid ${icon}"></i><div><div>${who} ${r.text ? `— ${esc(r.text)}` : ''}</div>${r.reason ? `<div class="muted aiv-log-reason">${esc(r.reason)}</div>` : ''}</div><div class="aiv-log-meta">${cost}<span class="muted">${tsAgo(r.created_at)}</span></div></div>`;}async function pollLog() {@@@ -272,7 +272,7 @@const p = d.plan;box.innerHTML = `<div class="muted" style="font-size:0.78rem">Context: ${d.context ? `${Math.round(d.context.stable_chars / 4)} tokens stable (cached) + ${Math.round(d.context.volatile_chars / 4)} tokens fresh · heard ${d.context.sources.heardAgeMs != null ? ago(d.context.sources.heardAgeMs) : 'nothing'} · seen ${d.context.sources.seenAgeMs != null ? ago(d.context.sources.seenAgeMs) : 'never'} · ${d.context.sources.chatLines} chat lines · ${d.context.sources.people} people known` : ''}${d.cost != null ? ` · this preview cost ${money(d.cost)}${d.usage && d.usage.cached ? ` (${Math.round((d.usage.cached / d.usage.input) * 100)}% cached)` : ''}` : ''}</div>-- ${p && p.lines && p.lines.length ? p.lines.map(l => `<div class="aiv-preview-line"><b>${esc(l.bot)}</b> <span class="muted">→ ${esc(l.target)}${l.reply_to ? ' @' + esc(l.reply_to) : ''} · +${Math.round((l.delay_ms || 0) / 1000)}s</span><br>${esc(l.text)}<br><span class="muted aiv-log-reason">${esc(l.reason || '')}</span></div>`).join('') : `<p class="muted">The director would stay quiet right now${p && p.notes ? ` — ${esc(p.notes)}` : ''}.</p>`}++ ${p && p.lines && p.lines.length ? p.lines.map(l => `<div class="aiv-preview-line"><b>${esc(l.bot)}</b> <span class="muted">to ${esc(l.target)}${l.reply_to ? ' @' + esc(l.reply_to) : ''} · +${Math.round((l.delay_ms || 0) / 1000)}s</span><br>${esc(l.text)}<br><span class="muted aiv-log-reason">${esc(l.reason || '')}</span></div>`).join('') : `<p class="muted">The director would stay quiet right now${p && p.notes ? ` — ${esc(p.notes)}` : ''}.</p>`}<details style="margin-top:6px"><summary class="muted" style="cursor:pointer;font-size:0.78rem">What the bots were shown</summary><pre class="aiv-pre">${esc(d.stable_preview || '')}\n\n---\n\n${esc(d.volatile_preview || '')}</pre></details>`;} catch (e) { if (box) box.innerHTML = `<span class="muted">${esc(e.message || 'Preview failed')}</span>`; }finally { if (btn) btn.disabled = false; }@@@ -3607,7 +3607,7 @@ function _startCaptions(username, displayName, isLive) {panel.classList.remove('collapsed');panel.innerHTML = `<div class="ch-captions-head"><span class="ch-captions-title"><span class="ch-captions-dot"></span> <i class="fa-solid fa-closed-captioning"></i> Live translation</span>-- <span class="ch-captions-sub">${esc(_chLang.flag || '')} ${esc(_chLang.name || '')} → 🇺🇸 English · what ${esc(displayName)} is saying</span>++ <span class="ch-captions-sub">${esc(_chLang.flag || '')} ${esc(_chLang.name || '')} to 🇺🇸 English · what ${esc(displayName)} is saying</span><button class="ch-captions-toggle" type="button" onclick="toggleCaptionsPanel()" title="Collapse / expand"><i class="fa-solid fa-chevron-up"></i></button></div><div class="ch-captions-lines" id="ch-captions-lines"><div class="ch-captions-wait">Listening… lines land a few seconds behind live.</div></div>`;@@@ -86,7 +86,7 @@if (/Firefox\//.test(ua)) return 'Firefox shows the prompt just under the address bar. Tick “Remember this decision” so it stops asking.';if (/Edg\//.test(ua)) return 'Edge shows the prompt under the address bar, on the left.';if (/Chrome\//.test(ua)) return 'Chrome shows the prompt under the address bar, on the left.';-- if (/Safari\//.test(ua)) return 'Safari asks once per site — if you miss it, use Safari → Settings for This Website.';++ if (/Safari\//.test(ua)) return 'Safari asks once per site — if you miss it, use Settings for This Website in Safari.';return 'Your browser will show the prompt near the address bar.';}@@@ -94,7 +94,7 @@const ua = navigator.userAgent;const what = KIND[kind]?.label || 'device';if (/Firefox\//.test(ua)) return `Click the padlock in the address bar, then clear the blocked ${what} permission and reload.`;-- if (/Safari\//.test(ua)) return `Open Safari → Settings for This Website, set ${what} to Allow, then reload.`;++ if (/Safari\//.test(ua)) return `Open Settings for This Website in Safari, set ${what} to Allow, then reload.`;return `Click the padlock (or camera icon) in the address bar, set ${what} to Allow, then reload.`;}@@@ -717,7 +717,7 @@ function _wsRenderPanel() {<label class="bc-ws-saver"><input type="checkbox" id="bc-ws-battery-saver"${typeof batterySaverOn === 'function' && batterySaverOn() ? ' checked' : ''}onchange="setBatterySaver(this.checked); if (broadcastState.activeStreamId) applyBroadcastQuality().catch(() => {})">-- <span><b>Battery saver on this device</b><small>480p at 30 fps, whatever is set above: a cooler phone and a longer stream. Change it while live under More → Video.</small></span>++ <span><b>Battery saver on this device</b><small>480p at 30 fps, whatever is set above: a cooler phone and a longer stream. Change it while live under More, then Video.</small></span></label></details></div>@@@ -793,7 +793,7 @@ function _wsRenderPanel() {</label><p class="muted" style="font-size:0.78rem;margin:4px 0 0">Covers OpenVibe chat, RobotStreamer chat and every restream chat relay on this slot.-- Each restream destination below also has its own <strong>→ PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.++ Each restream destination below also has its own <strong>PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.</p></div></div>@@@ -1785,7 +1785,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {<span class="bc-ws-method-info-note">Use the key shown above</span></div><p class="bc-ws-method-info-hint">-- <strong>OBS:</strong> Settings → Stream → Service: Custom → paste server & key above.<br>++ <strong>OBS:</strong> Settings, then Stream, then Service: Custom, then paste server & key above.<br><strong>Streamlabs / IRL Pro:</strong> Use Custom RTMP with the same server & key.</p></div>`;@@@ -1823,7 +1823,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {<span class="bc-ws-method-info-note">Use the stream key shown above as the Bearer Token in OBS.</span></div><p class="bc-ws-method-info-hint">-- <strong>OBS WHIP setup:</strong> Settings → Stream → Service: WHIP → paste URL above.++ <strong>OBS WHIP setup:</strong> Settings, then Stream, then Service: WHIP, then paste URL above.Set Bearer Token to your stream key. Your stream will auto-start when OBS begins transmitting.</p></div>`;@@@ -1871,7 +1871,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre><h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>-- <pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera → JSMPEG++ <pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera to JSMPEGrpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\--codec yuv420 -o - | \\ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -r 24 -i - \\@@@ -1936,7 +1936,7 @@ rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\-f flv ${esc(rtmpServer)}/${esc(streamKey)}</pre><h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>-- <pre class="bc-ws-cli-code"># Pi Camera → RTMP++ <pre class="bc-ws-cli-code"># Pi Camera to RTMPrpicam-vid -t 0 --width 1280 --height 720 --framerate 30 \\--codec h264 --bitrate 2000000 -o - | \\ffmpeg -f h264 -i - -c:v copy -an \\@@@ -54,7 +54,7 @@ function parseRsRestreamSlotValue() {// Migrate to managed format in-placeconst migratedId = ss.streamData.managed_stream_id;localStorage.setItem('bc-rs-restream-slot', `m:${migratedId}`);-- console.log(`[RS Slot] Migrated legacy s:${legacyId} → m:${migratedId}`);++ console.log(`[RS Slot] Migrated legacy s:${legacyId} to m:${migratedId}`);return { managedStreamId: migratedId };}}@@@ -222,7 +222,7 @@function renderSoundTab(body) {body.innerHTML = `<form class="cu-form" id="cu-sound-form">-- <input type="text" id="cu-sound-cmd" maxlength="24" placeholder="Command name (e.g. airhorn) → !airhorn" autocomplete="off">++ <input type="text" id="cu-sound-cmd" maxlength="24" placeholder="Command name (e.g. airhorn, then !airhorn)" autocomplete="off"><input type="file" id="cu-sound-file" accept="audio/*" multiple><input type="text" id="cu-sound-emote" maxlength="32" placeholder="Attach an emote code (optional) — shows the emote instead of “played !cmd”" autocomplete="off"><div id="cu-sound-preview" style="display:none;margin:2px 0"></div>@@@ -303,7 +303,7 @@</div><div class="cu-set-group"><div class="cu-set-group-title">Channel Points</div>-- <div class="cu-hint">Channel Points, rewards, and the redemption queue moved to your <b>Dashboard → Points</b> tab.</div>++ <div class="cu-hint">Channel Points, rewards, and the redemption queue moved to the <b>Points</b> tab in your <b>Dashboard</b>.</div></div><button class="cu-btn" id="cu-set-save" type="button">Save channel settings</button><div class="cu-hint">Applies to everyone in your channel's chat. Pitch/speed limits live in your dashboard's moderation panel.</div>@@@ -1871,7 +1871,7 @@ function _updateTranslateHint(lang) {if (!pill) { pill = document.createElement('span'); pill.className = 'chat-translate-hint'; row.appendChild(pill); }const name = _LANG_LABEL[_chatChannelLang] || _chatChannelLang;pill.title = `This streamer reads ${name}. Type in YOUR language — every message is auto-translated to ${name} for them, and their ${name} is translated to English for you.`;-- pill.innerHTML = `<i class="fa-solid fa-language"></i> <span>Auto-translated ↔ ${esc(name)}</span>`;++ pill.innerHTML = `<i class="fa-solid fa-language"></i> <span>Auto-translated with ${esc(name)}</span>`;});}@@@ -2824,13 +2824,13 @@ function addRichSystemMessage(html, style = 'info') {/* ── Event cards: someone went live, a Rundown episode is out ──────────────────* Live posts these as system rows ("🔴 <name> is live. Share the stream: <url>", metadata.kind 'live_link';-- * "📰 <name>: “<title>” → <url>", metadata.rundown_episode). They were italic blue sentences with a bare URL; now++ * "📰 <name>: “<title>” at <url>", metadata.rundown_episode). They were italic blue sentences with a bare URL; now* each is a small card like the deploy card: what happened, who, when (with the viewer's timestamp setting, always in* global chat as messages are), a Watch/Read button and a copy-link button. Old rows are recognised by their text.* The same person going live again within 15 minutes updates the card instead of adding another. Built from DOM* nodes; a link is used only when it is an OpenVibe address. */const _CHAT_EV_LIVE_RE = /^\s*\u{1F534}\s*(.+?) is live\. Share the stream:\s*(https?:\/\/\S+)\s*$/u;--const _CHAT_EV_RUNDOWN_RE = /^\s*\u{1F4F0}\s*(.+?):\s*[“"](.+)[”"]\s*→\s*(https?:\/\/\S+)\s*$/u;++const _CHAT_EV_RUNDOWN_RE = /^\s*\u{1F4F0}\s*(.+?):\s*[“"](.+)[”"]\s+(?:\u2192|at)\s+(https?:\/\/\S+)\s*$/u;const _CHAT_EV_FOLD_MS = 15 * 60 * 1000;function _chatEvSafeUrl(u) {try {@@@ -4272,7 +4272,7 @@ async function ctxRenameUsername(username, userId) {if (newUsername == null || !newUsername.trim()) return;try {await api(`/admin/users/${userId}`, { method: 'PUT', body: { username: newUsername.trim() } });-- toast(`Username changed: @${username} → @${newUsername.trim()}`, 'success');++ toast(`Username changed: @${username} to @${newUsername.trim()}`, 'success');} catch (err) {toast('Failed to rename username: ' + (err.message || 'unknown error'), 'error');}@@@ -4284,7 +4284,7 @@ async function ctxRenameDisplayName(username, userId, currentDisplay) {if (newName == null || !newName.trim()) return;try {await api(`/admin/users/${userId}`, { method: 'PUT', body: { display_name: newName.trim() } });-- toast(`Display name changed: @${username} → ${newName.trim()}`, 'success');++ toast(`Display name changed: @${username} to ${newName.trim()}`, 'success');} catch (err) {toast('Failed to rename display name: ' + (err.message || 'unknown error'), 'error');}@@@ -477,7 +477,7 @@ function showEndpointInfo(stream) {} else if (proto === 'webrtc') {info = `Protocol: WebRTC\n\nUse the browser "Start Broadcasting" feature.\nYour webcam/screen will be shared via Mediasoup SFU.`;} else if (proto === 'rtmp') {-- info = `Protocol: RTMP\n\nServer: rtmp://${host}:1935/live\nStream Key: ${stream.stream_key || '(use your stream key)'}\n\nIn OBS: Settings → Stream → Custom → paste above.`;++ info = `Protocol: RTMP\n\nServer: rtmp://${host}:1935/live\nStream Key: ${stream.stream_key || '(use your stream key)'}\n\nIn OBS: Settings, then Stream, then Custom, then paste above.`;}el.textContent = info;@@@ -991,7 +991,7 @@ function _bucksTxRow(t) {else if (t.type === 'donation') { icon = 'fa-gift'; if (!t.label) label = `Received from ${esc(t.from_display || t.from_username || 'someone')}`; sign = '+'; cls = 'pos'; }else if (t.type === 'cashout' || t.type === 'cashout_request') { icon = 'fa-money-bill-transfer'; if (!t.label) label = 'Cashout requested'; sign = '−'; cls = 'neg'; }else if (t.type === 'refund') { icon = 'fa-rotate-left'; if (!t.label) label = 'Cashout refunded'; sign = '+'; cls = 'pos'; }-- else if (t.type === 'recycle') { icon = 'fa-recycle'; if (!t.label) label = 'Moved cashout → spendable'; sign = ''; cls = ''; }++ else if (t.type === 'recycle') { icon = 'fa-recycle'; if (!t.label) label = 'Moved cashout to spendable'; sign = ''; cls = ''; }const url = t.subject && t.subject.url;const text = url && /^https:\/\//.test(url)? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>` : esc(label);@@@ -172,8 +172,8 @@const d = el.querySelector('#ovg-method-detail'); if (!d) return;const key = D.key || '(loading…)';if (D.method === 'browser') d.innerHTML = ui.box('Browser streaming', `<p>When you press <b>Go live</b> we'll ask for your camera and mic (or a screen to share). Nothing to install.</p><div class="ovg-seg"><button type="button" class="${D.mode === 'camera' ? 'on' : ''}" data-mode="camera"><i class="fa-solid fa-video"></i> Camera + mic</button><button type="button" class="${D.mode === 'screen' ? 'on' : ''}" data-mode="screen"><i class="fa-solid fa-display"></i> Share my screen</button></div><ul class="ovg-tips"><li>Phones work great — landscape looks best.</li><li>Good light beats a good camera.</li></ul>`, 'fa-globe');-- else if (D.method === 'rtmp') d.innerHTML = ui.box('Paste these into OBS', `${ui.kv('Server', D.rtmpUrl || 'rtmp://openvibe.live/live', true)}${ui.secret('Stream key', key)}${ui.steps(['<b>Settings</b> → <b>Stream</b>', 'Service: <b>Custom…</b>', 'Paste the server and the key', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">Keep the key private — anyone with it can stream as you. You can regenerate it on the Go Live page.</p>`, 'fa-desktop');-- else d.innerHTML = ui.box('OBS 30+ over WHIP', `${ui.kv('Server', `${D.whipBase || location.origin}/whip/${D.slot.id}`, true)}${ui.secret('Bearer token', key)}${ui.steps(['<b>Settings</b> → <b>Stream</b>', 'Service: <b>WHIP</b>', 'Paste the server and the bearer token', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">WHIP is WebRTC: viewers see you with under a second of delay.</p>`, 'fa-bolt');++ else if (D.method === 'rtmp') d.innerHTML = ui.box('Paste these into OBS', `${ui.kv('Server', D.rtmpUrl || 'rtmp://openvibe.live/live', true)}${ui.secret('Stream key', key)}${ui.steps(['<b>Settings</b>, then <b>Stream</b>', 'Service: <b>Custom…</b>', 'Paste the server and the key', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">Keep the key private — anyone with it can stream as you. You can regenerate it on the Go Live page.</p>`, 'fa-desktop');++ else d.innerHTML = ui.box('OBS 30+ over WHIP', `${ui.kv('Server', `${D.whipBase || location.origin}/whip/${D.slot.id}`, true)}${ui.secret('Bearer token', key)}${ui.steps(['<b>Settings</b>, then <b>Stream</b>', 'Service: <b>WHIP</b>', 'Paste the server and the bearer token', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">WHIP is WebRTC: viewers see you with under a second of delay.</p>`, 'fa-bolt');ui.wire(d);d.querySelectorAll('[data-mode]').forEach(b => b.addEventListener('click', async () => { D.mode = b.dataset.mode; d.querySelectorAll('[data-mode]').forEach(x => x.classList.remove('on')); b.classList.add('on'); try { await api(`/streams/managed/${D.slot.id}`, { method: 'PUT', body: { browser_mode: D.mode } }); D.slot.browser_mode = D.mode; } catch { /* */ } }));};@@@ -227,9 +227,9 @@const [name, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';const oauth = (D.oauth && D.oauth[plat]) || null;const canConnect = !!(oauth && oauth.configured);-- const manualHelp = plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.'-- : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.'-- : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.'++ const manualHelp = plat === 'twitch' ? 'Twitch: open Creator Dashboard, then Settings, then Stream, then copy the <b>Primary Stream key</b>.'++ : plat === 'youtube' ? 'YouTube Studio: go live, then copy the <b>Stream key</b>.'++ : plat === 'kick' ? 'Kick: Creator Dashboard, then Settings, then Stream key: copy the <b>URL</b> and the <b>key</b>.': 'Paste the RTMP server URL and stream key from the service you want to mirror to.';// Two ways in, and the reader picks. Signing in is one tap and fills the key in for them;@@@ -313,7 +313,7 @@one('offline', 'Offline screen', 'fa-image', async () => `${ui.lead('What visitors see on your channel when you are not live: an image, a looping video, or your own HTML page. It sits above your top content and the discover board.')}${ui.grid([ui.pick({ id: 'image', icon: 'fa-solid fa-image', title: 'Image', sub: 'a banner or a still from your stream' }), ui.pick({ id: 'video', icon: 'fa-solid fa-film', title: 'Video loop', sub: 'a short muted clip' }), ui.pick({ id: 'html', icon: 'fa-solid fa-code', title: 'Custom HTML', sub: 'anything you can build' })], 3)}<p class="muted ovg-fine">The upload lives in your dashboard — the button below takes you straight to it and points at the right spot.</p>`,(el) => { el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); })); },async () => { dashboard('#dash-card-offline', 'Offline Screen', 'Pick an image, a looping video or your own HTML — this is what visitors see when you are not live.'); return true; }, 'Open the offline screen settings');-- one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b> → <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,++ one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b>, then <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,null, async () => { G.close(false); location.href = '/api/powerchat/oauth/start'; return true; }, 'Connect PowerChat');@@@ -95,7 +95,7 @@: (notes.length ? notes.map(d => d.message).join(' '): (okDiag ? okDiag.message : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.'));// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.-- hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;++ hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a>, then a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;}} else {_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');@@@ -654,7 +654,7 @@ button{font:inherit}<script>(() => {"use strict";-- // Served from openvibe.live/kiosk → everything is same-origin (no CORS, no cross-host WS).++ // Served from openvibe.live/kiosk, so everything is same-origin (no CORS, no cross-host WS).const API="/api";const WS_URL=(location.protocol==="https:"?"wss://":"ws://")+location.host+"/ws/chat";const $=s=>document.querySelector(s);@@@ -1046,7 +1046,7 @@ button{font:inherit}const LS_PROV="openvibe_kiosk_search", LS_HIST="openvibe_kiosk_history";let provider=localStorage.getItem(LS_PROV); if(!PROVIDERS[provider]) provider="google";let hist=[]; try{ hist=JSON.parse(localStorage.getItem(LS_HIST)||"[]"); if(!Array.isArray(hist)) hist=[]; }catch{}-- const resolved=new Map(); // domain(lowercased) → {reachable,url,host,title}++ const resolved=new Map(); // domain (lowercased): {reachable,url,host,title}const resolving=new Set();let curItems=[], selIdx=-1, isOpen=false, resolveTimer=null;@@@ -1130,7 +1130,7 @@ button{font:inherit}if(looksLikeDomain(q)){const key=q.toLowerCase(), c=resolved.get(key);if(c && !c.reachable){-- // resolved as unreachable → offer search only++ // resolved as unreachable, so offer search only} else if(c && c.reachable){add({type:"domain",title:c.title||q,sub:c.host+(c.title?" · "+c.title:""),host:c.host,url:c.url});} else {@@@ -1244,7 +1244,7 @@ button{font:inherit}document.addEventListener("click",e=>{ if(!provMenu.hidden && !provMenu.contains(e.target) && e.target!==provBtn && !provBtn.contains(e.target)) setProvMenu(false); });document.addEventListener("keydown",e=>{ if(e.key==="Escape" && !provMenu.hidden){ e.stopPropagation(); setProvMenu(false); } },true);-- // Global: start typing anywhere → focus the (possibly ghosted or hidden) bar and seed it.++ // Global: start typing anywhere, focus the (possibly ghosted or hidden) bar and seed it.document.addEventListener("keydown",e=>{if(document.activeElement===input) return; // already typing in the bar — let it beif(e.key==="Escape") return;@@@ -502,7 +502,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4let reconnectDelay = RECONNECT_BASE;let reconnectTimer = null;let intentionalClose = false;-- let emoteMap = {}; // code → {url, animated}++ let emoteMap = {}; // code: {url, animated}let fadeTimers = [];// ── URL params for customization ───────────────────────────@@@ -531,7 +531,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4document.body.classList.add('no-shadow');}}-- // ?streamtitle=1 → show which stream slot each message came from (default off).++ // ?streamtitle=1 shows which stream slot each message came from (default off).// Handy for a per-user overlay aggregating several live slots.const showStreamTitle = ['1', 'true', 'on', 'yes'].includes(String(params.get('streamtitle') || '').toLowerCase());@@@ -629,7 +629,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4YouTube: '#ff0000',RS: '#7dd3fc',};-- // Relay platform → icon badge (replaces the old "[Twitch]/[RS]" text prefix).++ // Relay platform: icon badge (replaces the old "[Twitch]/[RS]" text prefix).const RELAY_BADGE = {twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch' },kick: { icon: 'fa-solid fa-bolt', label: 'Kick' },@@@ -705,7 +705,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4// Suppress noisy OpenVibe.Live join/system lines (a clean "connected" shows on open).if (msg.type === 'system' && OV_JOIN_RE.test(_rawText)) return;-- // RobotStreamer relayed noise → suppress; announce RS connection once on first RS msg.++ // RobotStreamer relayed noise: suppress; announce RS connection once on first RS msg.if (_isRs) {if (!_rsConnectedShown) { _rsConnectedShown = true; _showConnected('Connected to RobotStreamer chat successfully'); }if (RS_NOISE_RE.test(_rawText)) return;@@@ -275,7 +275,7 @@(function parseUrl() {const seg = window.location.pathname.split('/').filter(Boolean); // ['popout'|'popout-chat', user?, id?]if (seg[0] === 'popout' || seg[0] === 'popout-chat') {-- // No username (or explicit "global") → global chat.++ // No username (or explicit "global") means global chat.if (!seg[1] || seg[1].toLowerCase() === 'global') { mode = 'global'; return; }mode = 'channel';channelName = decodeURIComponent(seg[1]);@@@ -314,7 +314,7 @@// across tabs by the shared storage listener.// Request the viewer list over the shared chat WebSocket. The reply is-- // handled by chat.js → renderChatUsersList(), which now also fills #pc-users-body.++ // handled by chat.js: renderChatUsersList(), which now also fills #pc-users-body.window.requestUserList = function() {try {if (typeof chatWs !== 'undefined' && chatWs && chatWs.readyState === WebSocket.OPEN) {@@@ -407,9 +407,9 @@}// Decide which stream the popout SHOULD be in right now:-- // pinned stream while it's live → the stream we're already in while it's live-- // (concurrent slots can reorder by viewer count — never flap between them) →-- // any other live stream → channel chat (null).++ // pinned stream while it's live: the stream we're already in while it's live++ // (concurrent slots can reorder by viewer count — never flap between them), then++ // any other live stream: channel chat (null).function desiredTarget(liveStreams) {const liveIds = liveStreams.map(s => Number(s.id));if (pinnedStreamId && liveIds.includes(Number(pinnedStreamId))) return Number(pinnedStreamId);@@@ -69,7 +69,7 @@<div class="card"><label for="whip-url">WHIP URL</label><input id="whip-url" type="url" placeholder="https://ingest.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false">-- <p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>++ <p class="hint">Copy it from <strong>Broadcast, then your stream, then Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p><label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label><input id="whip-token" type="password" placeholder="stream key" autocomplete="off">@@@ -216,7 +216,7 @@els.preview.srcObject = stream;for (const track of stream.getTracks()) {log(`${track.kind} track: ${track.label || 'unnamed'}`);-- // "Stop sharing" in the browser's screen-share bar ends the track → end the session.++ // "Stop sharing" in the browser's screen-share bar ends the track, so end the session.track.addEventListener('ended', () => { if (state.phase === 'live' || state.phase === 'connecting') stop('source ended'); });}@@@ -77,7 +77,7 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {if (!res.ok && res.status !== 202) {// AI is resting (ai.resting): keep its sentence so a product can show it instead of "could not answer".if (res.status === 503 && json && json.code === 'ai.resting') _lastProblem = { ...RESTING, retry_after_seconds: Number(res.headers.get('retry-after')) || null };-- warn(`${method} ${path} -> ${res.status} ${(json && (json.code || json.detail)) || ''}`);++ warn(`${method} ${path} returned ${res.status} ${(json && (json.code || json.detail)) || ''}`);return null;}return json;@@@ -183,7 +183,7 @@ async function _checkLiveStream(stream) {// deduplicated: Media handed back a clip someone already cut of this window. It stays in// the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).await _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: clipStart, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: verdict.desc || '', speech, sig: _sceneSig(verdict.desc || title), score: rated.score, visibility: rated.verdict === 'public' ? 'public' : 'unlisted', bucket: rated.bucket, peak: peak.count, chatters: chatters == null ? undefined : chatters });-- console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs, score ${rated.score} → ${rated.verdict}${cut.snapped ? ', on sentences' : ''}`);++ console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs, score ${rated.score}, verdict ${rated.verdict}${cut.snapped ? ', on sentences' : ''}`);}} catch (e) {console.warn(`[AutoClip] live check failed for stream ${streamId}:`, e.message);@@@ -454,7 +454,7 @@ async function clipVodMoment(o) {await _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: desc || '', speech, archival: archival || undefined, sig: _sceneSig(desc || title),score: rated.score, visibility: wanted, bucket: rated.bucket, peak: Number(sp.peak) || 0, chatters: evidence.chatters == null ? undefined : evidence.chatters,picked_by: evidence.picked_by || undefined, policy: VOD_POLICY, verified: verified ? verified.verdict : undefined });-- console.log(`[AutoClip] VOD ${vodId} @${Math.round(offset)}s cut (${evidence.picked_by || 'signals'}) — score ${rated.score} → ${wanted}${verified ? ` (check: ${verified.verdict})` : ''}, bucket ${rated.bucket}${cut.snapped ? ', on sentences' : ''}`);++ console.log(`[AutoClip] VOD ${vodId} @${Math.round(offset)}s cut (${evidence.picked_by || 'signals'}) — score ${rated.score}, verdict ${wanted}${verified ? ` (check: ${verified.verdict})` : ''}, bucket ${rated.bucket}${cut.snapped ? ', on sentences' : ''}`);}return clip || null;} catch { return null; }@@@ -224,7 +224,7 @@ async function threadsBlock(channelUserId, limit) {rows,text: rows.map(t => {let who = []; try { who = JSON.parse(t.participants_json || '[]'); } catch { /* */ }-- return `- thread #${t.id} (${t.kind.replace('_', '↔')}, ${who.join(' & ')}${t.topic ? `, about: ${clip(t.topic, 60)}` : ''}, ${t.turns} turns)${t.last_line ? ` last: ${t.last_line_by}: "${clip(t.last_line, 120)}"` : ''}${t.awaiting ? ` — waiting on ${t.awaiting}` : ''}`;++ return `- thread #${t.id} (${t.kind.replace('_', ' to ')}, ${who.join(' & ')}${t.topic ? `, about: ${clip(t.topic, 60)}` : ''}, ${t.turns} turns)${t.last_line ? ` last: ${t.last_line_by}: "${clip(t.last_line, 120)}"` : ''}${t.awaiting ? ` — waiting on ${t.awaiting}` : ''}`;}).join('\n'),};}@@@ -37,7 +37,7 @@ function plainText(value) {}text = db.publicMemoryText(text).replace(/<[^>]*>/g, ' ')-- .replace(/-->/g, '→')++ .replace(/-->/g, ' ').replace(/\s+/g, ' ').trim();return text;@@@ -372,7 +372,7 @@ async function startContinuousCapture(stream) {};ff.on('close', onExit);ff.on('error', (e) => onExit(e && e.message));-- if (!retry) console.log(`[AI-Hear] stream ${stream.id}: continuous capture started (${SEGMENT_SEC}s segments → ${dir})`);++ if (!retry) console.log(`[AI-Hear] stream ${stream.id}: continuous capture started (${SEGMENT_SEC}s segments to ${dir})`);return true;}@@@ -206,7 +206,7 @@ let _interval = null;function start() {if (_interval) return;_interval = setInterval(() => { tick().catch(() => {}); }, POLL_MS);-- console.log('[AI] Timeline job started (continuous audio → stream_timeline_events)');++ console.log('[AI] Timeline job started (continuous audio to stream_timeline_events)');}/**@@@ -256,7 +256,7 @@ class AiViewersEngineV3 {const accepted = await this._acceptPlan(w, res.plan, bots, consumed);await poster.log(w, {event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),-- text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,++ text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot} to ${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,});for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { await db.closeAiViewerThread(n); } catch { /* */ } } }@@@ -27,12 +27,12 @@ async function syncUsername(userId, newName) {const user = await db.get('SELECT id, username FROM users WHERE id = ?', [userId]);if (!user || String(user.username || '').toLowerCase() === newName.toLowerCase()) return false;const clash = await db.get('SELECT id FROM users WHERE lower(username) = lower(?) AND id != ?', [newName, userId]);-- if (clash) { console.warn(`[Usernames] ${user.username} → ${newName}: another Live user holds that name; not renamed`); return false; }++ if (clash) { console.warn(`[Usernames] ${user.username} to ${newName}: another Live user holds that name; not renamed`); return false; }await db.getDb().tx(async () => {await db.run('INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, ?, ?)', [user.id, user.username, newName]);await db.run('UPDATE users SET username = ? WHERE id = ?', [newName, user.id]);});-- console.log(`[Usernames] renamed ${user.username} → ${newName} (Network)`);++ console.log(`[Usernames] renamed ${user.username} to ${newName} (Network)`);return true;}@@@ -80,7 +80,7 @@ function problems(env = process.env, { repoRoot = REPO_ROOT, cwd = process.cwd()const abs = path.resolve(cwd, value);const real = realish(abs);const root = roots.find((r) => inside(abs, r) || inside(real, r));-- if (root) out.push(`${name} (${abs}${real !== abs ? ` → ${real}` : ''}) is inside ${root}; a drill writes nothing in the checkout or in production's data`);++ if (root) out.push(`${name} (${abs}${real !== abs ? ` resolves to ${real}` : ''}) is inside ${root}; a drill writes nothing in the checkout or in production's data`);};// The database: the restored copy, never production's. Boot migrates through DATABASE_DIRECT_URL, so it must name// the copy too whenever DATABASE_URL does.@@@ -111,7 +111,7 @@ function init({ eventsUrl = EVENTS_URL, clientSecret = CLIENT_SECRET, fetchImpl,outbox.start();pruneTimer = setInterval(() => { if (outbox) outbox.prune().catch(() => { /* next time */ }); }, PRUNE_EVERY_MS);if (pruneTimer.unref) pruneTimer.unref();-- outbox.pending().then((n) => console.log(`[Events] stream lifecycle → ${eventsUrl} (${n} pending)`), () => {});++ outbox.pending().then((n) => console.log(`[Events] stream lifecycle to ${eventsUrl} (${n} pending)`), () => {});return outbox;}@@@ -151,7 +151,7 @@ class ChatRelayService {// every relayed message is duplicated (once per stale overlapping bridge).for (const [existingKey, b] of this.bridges) {if (b.destId === dest.id && b.streamId !== streamId) {-- console.log(`[ChatRelay] Replacing stale bridge ${existingKey} -> ${key} (same destination, prevents duplicate relay)`);++ console.log(`[ChatRelay] Replacing stale bridge ${existingKey} with ${key} (same destination, prevents duplicate relay)`);this._teardown(b);this.bridges.delete(existingKey);}@@@ -881,7 +881,7 @@ class ChatRelayService {try {const current = await findLiveChatId();if (current && current !== liveChatId) {-- console.log(`[ChatRelay] YouTube: live chat rotated for stream ${bridge.streamId} → ${current}`);++ console.log(`[ChatRelay] YouTube: live chat rotated for stream ${bridge.streamId} to ${current}`);liveChatId = current;bridge._ytPageToken = undefined;bridge._ytFirstPoll = true; // don't dump the new chat's backlog@@@ -149,7 +149,7 @@ async function postForm(url, formObj, headers = {}) {try { json = text ? JSON.parse(text) : {}; } catch { /* non-json */ }if (!res.ok) {const msg = (json && (json.message || json.error_description || json.error)) || text || res.statusText;-- throw new Error(`${url} -> ${res.status}: ${msg}`);++ throw new Error(`${url} returned ${res.status}: ${msg}`);}return json || {};}@@@ -200,7 +200,7 @@ async function getJson(url, headers) {try { json = text ? JSON.parse(text) : {}; } catch { /* */ }if (!res.ok) {const msg = (json && (json.message || json.error?.message || json.error)) || text || res.statusText;-- throw new Error(`${url} -> ${res.status}: ${msg}`);++ throw new Error(`${url} returned ${res.status}: ${msg}`);}return json || {};}@@@ -286,7 +286,7 @@ async function createYouTubeStream(headers) {body: JSON.stringify(body),});const json = await res.json().catch(() => ({}));-- if (!res.ok) throw new Error(`YouTube liveStreams.insert -> ${res.status}: ${json.error?.message || res.statusText}`);++ if (!res.ok) throw new Error(`YouTube liveStreams.insert returned ${res.status}: ${json.error?.message || res.statusText}`);return json;}@@@ -859,7 +859,7 @@ class RobotStreamerService {};outgoing = JSON.stringify(msg);}-- console.log(`[RS Publish] Client → SFU: ${msg.method} (id=${msg.id}) | upstream ready: ${upstreamReady} | queued: ${outboundQueue.length}`);++ console.log(`[RS Publish] Client to SFU: ${msg.method} (id=${msg.id}) | upstream ready: ${upstreamReady} | queued: ${outboundQueue.length}`);}if (!upstreamReady) outboundQueue.push(outgoing);@@@ -992,11 +992,11 @@ class RobotStreamerService {const parsed = safeJsonParse(raw);if (parsed?.response) {const status = parsed.ok === false ? 'ERROR' : 'OK';-- console.log(`[RS Publish] SFU → client: response id=${parsed.id} ${status}`, parsed.ok === false ? JSON.stringify({ error: parsed.error, reason: parsed.reason }) : '');++ console.log(`[RS Publish] SFU to client: response id=${parsed.id} ${status}`, parsed.ok === false ? JSON.stringify({ error: parsed.error, reason: parsed.reason }) : '');} else if (parsed?.request) {-- console.log(`[RS Publish] SFU → client: request method=${parsed.method}`);++ console.log(`[RS Publish] SFU to client: request method=${parsed.method}`);} else if (parsed?.notification) {-- console.log(`[RS Publish] SFU → client: notification method=${parsed.method}`);++ console.log(`[RS Publish] SFU to client: notification method=${parsed.method}`);}});@@@ -482,7 +482,7 @@ class RsPassthroughRelay {session.state = 'restarting';session.nextRestartAt = Date.now() + delay;log(session.streamId, `⚠️ passthrough restart #${session.restarts}/${MAX_RESTARTS} in ${delay}ms — reason: ${reason} ` +-- `(RS viewers see this as the video cutting out: new SSRC → new consumer → client blanks the MediaStream)`);++ `(RS viewers see this as the video cutting out: new SSRC, then a new consumer, then the client blanks the MediaStream)`);session.restartTimer = setTimeout(async () => {session.restartTimer = null;if (session.stopped) return;@@@ -632,7 +632,7 @@ class RsPassthroughRelay {const isH264 = /h264/i.test(videoIn.mimeType || 'video/VP8');const videoCodec = isH264 ? useH264({ payloadType: 103 }) : useVP8({ payloadType: 101 });const audioCodec = useOPUS({ payloadType: 100 });-- log(sid, `source video codec: ${videoIn.mimeType} → producing ${videoCodec.mimeType}`);++ log(sid, `source video codec ${videoIn.mimeType}, producing ${videoCodec.mimeType}`);const pc = new RTCPeerConnection({codecs: { video: [videoCodec], audio: [audioCodec] },headerExtensions: {@@@ -1040,7 +1040,7 @@ class RsPassthroughRelay {// RS was told one codec; a source that changed codec needs a new RS producer (a restart).if (kind === 'video' && String(next.mimeType).toLowerCase() !== String(videoIn.mimeType).toLowerCase()) {closeIngest(next);-- log(sid, `source video codec changed (${videoIn.mimeType} → ${next.mimeType}) — restarting for a new RS producer`);++ log(sid, `source video codec changed (${videoIn.mimeType} to ${next.mimeType}) — restarting for a new RS producer`);this._teardown(session);this._scheduleRestart(session, 'source video codec changed').catch(err => log(sid, 'restart check error:', err.message));return false;@@@ -1050,7 +1050,7 @@ class RsPassthroughRelay {session.ingests = session.ingests.filter(i => i !== cur).concat(next);closeIngest(cur);stats.switches = (stats.switches || 0) + 1;-- log(sid, `↪ ${kind} now from producer ${producer.id} (${why}) — same RS producer and SSRC, no restart`);++ log(sid, `switched: ${kind} now from producer ${producer.id} (${why}) — same RS producer and SSRC, no restart`);if (kind === 'video') { reqKeyNow(0); setTimeout(() => reqKeyNow(250), 400); }return true;} finally { _switching = false; }@@@ -1143,7 +1143,7 @@ class RsPassthroughRelay {`aIn ${Math.round((stats.aIn - lastAIn) / 10)}/s aOut ${Math.round((stats.aOut - lastAOut) / 10)}/s${stalled} ` +`| writeErr v=${stats.vErr} a=${stats.aErr} alienSsrc v=${stats.vAlien} a=${stats.aAlien} | stalls=${stats.ingestStalls} (${Math.round(stats.ingestStallMs / 1000)}s total) ` +`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj}${INJECT_PARAMS ? '' : '(off)'} gopDmg=${stats.gopDamaged} ` +-- `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay) lateFill ${stats.lateFill - lastLateFill} ` +++ `| lossIN ${stats.lostIn - lastLostIn} (goosely to relay) lateFill ${stats.lateFill - lastLateFill} ` +`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} deepRtx=${stats.rtxDeep} rtxMiss=${stats.rtxMiss} rtxTooOld=${stats.rtxTooOld} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +`| rsRemb=${remb} bwe=${bwe} | keyReq=${stats.pli} werift=${pc.connectionState} dtls/ice=${vSenderState()}`);lastVIn = stats.vIn; lastVOut = stats.vOut; lastAIn = stats.aIn; lastAOut = stats.aOut;@@@ -291,7 +291,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {priority: 'normal',title: parentId ? `New reply on your ${target.label}` : `New comment on your ${target.label}`,message: `${actor.sender_name} ${parentId ? 'replied on' : 'commented on'} "${truncatePreview(target.title, 80)}"`,-- icon: parentId ? '↩️' : '💬',++ icon: '💬',...actor,service: 'live',url: `${target.url}#comments`,@@@ -311,7 +311,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {priority: 'normal',title: 'New reply to your comment',message: `${actor.sender_name} replied to your comment on "${truncatePreview(target.title, 80)}"`,-- icon: '↩️',++ icon: '💬',...actor,service: 'live',url: `${target.url}#comments`,@@@ -89,7 +89,7 @@ function toCommunity(subPath, whenDown = null) {}res.send(Buffer.from(await upstream.arrayBuffer()));} catch (err) {-- console.warn('[Pastes → Community]', err.message);++ console.warn('[Pastes to Community]', err.message);if (!res.headersSent) {if (whenDown) return res.json(whenDown(req));res.status(502).json({ error: 'Paste service unavailable' });@@@ -115,7 +115,7 @@ communityRouter.post('/:slug/set-avatar', requireAuth, async (req, res) => {} catch { /* next report */ }res.json({ success: true, avatar_url: stored });} catch (err) {-- console.warn('[Pastes → Community] set-avatar:', err.message);++ console.warn('[Pastes to Community] set-avatar:', err.message);res.status(500).json({ error: 'Failed to set avatar' });}});@@@ -35,19 +35,19 @@ async function handle(chat, ws, client, cmd, parts) {if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }const work = (async () => {try {-- if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead → ${base()}/rundown`);++ if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead: ${base()}/rundown`);const stream = client.streamId ? await db.getStreamById(client.streamId) : null;const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')]): (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);-- if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up → ${base()}/rundown`);++ if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up at ${base()}/rundown`);const read = require('./read');const page = await read.streamer(target.username);const name = target.display_name || target.username;if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);const ep = page.episodes[0];const topics = page.topics.slice(0, 3).map((t) => t.title);-- if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream → ${base()}/rundown`);-- return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} → ${base()}/rundown/${encodeURIComponent(target.username)}`);++ if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream. See ${base()}/rundown`);++ return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} at ${base()}/rundown/${encodeURIComponent(target.username)}`);} catch (err) {reply(`The Rundown: ${err.message}`);}@@@ -415,7 +415,7 @@ async function announce(stream, user, title) {let base = 'https://openvibe.live';try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }const url = `${base}/rundown/e/${stream.id}`;-- await require('../chat/chat-delivery').message({ username: 'THE RUNDOWN', user_id: null, message: `📰 ${user.display_name || user.username}: “${title}” → ${url}`.slice(0, 480), message_type: 'system', key: `rundown:episode:${stream.id}`, metadata: { rundown_episode: stream.id, url }, is_global: true });++ await require('../chat/chat-delivery').message({ username: 'THE RUNDOWN', user_id: null, message: `📰 ${user.display_name || user.username}: “${title}” at ${url}`.slice(0, 480), message_type: 'system', key: `rundown:episode:${stream.id}`, metadata: { rundown_episode: stream.id, url }, is_global: true });return true;}@@@ -17,6 +17,7 @@'use strict';const seo = require('openvibe-shared/seo');const kit = require('openvibe-shared/kit');++const glyphs = require('openvibe-shared/glyphs');const { esc } = seo;const { setRobotsHeader } = require('../web/robots-header');const db = require('../db/database');@@@ -278,7 +279,7 @@ async function _channelSectionMeta(username, section, page) {const hasNext = pages ? page < pages : rows.length === CHANNEL_PAGE_SIZE;const pager = `<nav class="pagination" aria-label="Pages">${page > 1 ? `<a rel="prev" href="${href(page - 1)}">Previous</a>` : ''}<span>Page ${page}${pages ? ` of ${pages}` : ''}</span>${hasNext ? `<a rel="next" href="${href(page + 1)}">Next</a>` : ''}</nav>`;const list = items.length ? `<ol>${items.map((item) => `<li><a href="${esc(abs(item.url))}">${esc(clean(item.name, 120))}</a><time>${esc(item.meta)}</time></li>`).join('')}</ol>` : '<p>No items on this page.</p>';-- const snapshot = `<p><a href="${esc(abs(`/@${acct.username}`))}">← ${esc(name)}'s channel</a></p><h1>${esc(name)} · ${esc(title)}</h1>${list}${pager}`;++ const snapshot = `<p><a href="${esc(abs(`/@${acct.username}`))}">${glyphs.svg('arrow-left', { size: 16 })} ${esc(name)}'s channel</a></p><h1>${esc(name)} · ${esc(title)}</h1>${list}${pager}`;return {title: `${name} — ${title}, page ${page} | ${SITE_NAME}`, description: `Browse ${name}'s ${title.toLowerCase()} on ${SITE_NAME}.`,canonicalPath, robots: page > 1 && !rows.length ? 'noindex,follow' : 'index,follow',@@@ -235,7 +235,7 @@ class StreamRecorder {const rtmpUrl = `rtmp://127.0.0.1:${config.rtmp.port}/live/${endpoint.streamKey}`;await media.ingestRtmp(rec.vodId, rtmpUrl);this._partStarted(stream, rec);-- console.log(`[VOD] Media RTMP ingest started: stream ${streamId} → vod ${rec.vodId} (${rec.mode})`);++ console.log(`[VOD] Media RTMP ingest started: stream ${streamId} to vod ${rec.vodId} (${rec.mode})`);}async _startRtpRecording(streamId, stream, rec) {@@@ -342,7 +342,7 @@ class StreamRecorder {rec.webrtcState = webrtcState;this._partStarted(stream, rec);-- console.log(`[VOD] Media RTP ingest started: stream ${streamId} → vod ${rec.vodId} (${videoPort ? `v:${videoPort}` : 'audio only'}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);++ console.log(`[VOD] Media RTP ingest started: stream ${streamId} to vod ${rec.vodId} (${videoPort ? `v:${videoPort}` : 'audio only'}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);}/**@@@ -425,7 +425,7 @@ class StreamRecorder {await media.deleteVod(rec.vodId).catch(() => {});console.log(`[VOD] Discarded ephemeral clips-only recording (vod ${rec.vodId}, stream ${streamId})`);} else {-- console.log(`[VOD] Recording stopped: stream ${streamId} → vod ${rec.vodId} (Media finalizing)`);++ console.log(`[VOD] Recording stopped: stream ${streamId} to vod ${rec.vodId} (Media finalizing)`);}})();finish.catch(() => {}).finally(() => this._finalizing.delete(streamId));@@@ -326,7 +326,7 @@ class RestreamManager extends EventEmitter {if (protocol === 'webrtc') {const webrtcSFU = require('./webrtc-sfu');if (!webrtcSFU.ready) {-- console.warn('[Restream] WebRTC → RTMP requires Mediasoup SFU. Mediasoup not available.');++ console.warn('[Restream] WebRTC to RTMP requires Mediasoup SFU. Mediasoup not available.');return null;}}@@@ -532,7 +532,7 @@ class RestreamManager extends EventEmitter {}/**-- * Start WebRTC → RTMP restream.++ * Start WebRTC to RTMP restream.** Flow:* 1. Signal the broadcaster to produce tracks into Mediasoup SFU@@@ -413,11 +413,11 @@ router.post('/destinations/:id/start', requireAuth, async (req, res) => {}if (stream.protocol === 'webrtc') {-- // WebRTC → RTMP requires Mediasoup SFU++ // WebRTC to RTMP requires Mediasoup SFUconst webrtcSFU = require('./webrtc-sfu');if (!webrtcSFU.ready) {return res.status(400).json({-- error: 'WebRTC → RTMP restreaming requires Mediasoup. Install mediasoup: npm install mediasoup',++ error: 'WebRTC to RTMP restreaming requires Mediasoup. Install mediasoup: npm install mediasoup',});}}@@@ -650,7 +650,7 @@ router.get('/oauth/:platform/start', requireAuth, async (req, res) => {function oauthErrorText(platform, err, description) {const name = (platformOAuth.PLATFORMS[platform] && platformOAuth.PLATFORMS[platform].name) || String(platform).charAt(0).toUpperCase() + String(platform).slice(1);if (/redirect/i.test(String(err || '')) || /redirect[_ ]?ur[il]/i.test(String(description || ''))) {-- return `${name} refused the sign-in because this site's address is not on its app's list. In the ${name} developer console, add ${platformOAuth.redirectUri(platform)} as an OAuth redirect URL for the app whose client ID is set in Admin → Integrations, then connect again. Until then, paste the stream key into the destination by hand.`;++ return `${name} refused the sign-in because this site's address is not on its app's list. In the ${name} developer console, add ${platformOAuth.redirectUri(platform)} as an OAuth redirect URL for the app whose client ID is set in Admin, under Integrations, then connect again. Until then, paste the stream key into the destination by hand.`;}return description || err;}@@@ -477,8 +477,8 @@ npm run init-db</code></pre><h3>Main website</h3><p>Create these records in Cloudflare DNS:</p><ul>--<li><code>A</code> record for <code>@</code> → <code>YOUR_OVH_PUBLIC_IP</code></li>--<li><code>CNAME</code> for <code>www</code> → your apex hostname</li>++<li><code>A</code> record for <code>@</code> pointing at <code>YOUR_OVH_PUBLIC_IP</code></li>++<li><code>CNAME</code> for <code>www</code> pointing at your apex hostname</li></ul><p>Set both to <strong>Proxied</strong> (orange cloud) after the origin is working.</p>@@@ -1031,18 +1031,18 @@ sudo ufw status verbose</code></pre><h3>Approximate retained recording hours (at 2.5 Mbps)</h3><ul>--<li>30 GB usable → ~27 hours</li>--<li>80 GB usable → ~73 hours</li>--<li>180 GB usable → ~164 hours</li>--<li>380 GB usable → ~345 hours</li>++<li>30 GB usable, about 27 hours</li>++<li>80 GB usable, about 73 hours</li>++<li>180 GB usable, about 164 hours</li>++<li>380 GB usable, about 345 hours</li></ul><h3>At 4.0 Mbps</h3><ul>--<li>30 GB usable → ~17 hours</li>--<li>80 GB usable → ~45 hours</li>--<li>180 GB usable → ~102 hours</li>--<li>380 GB usable → ~216 hours</li>++<li>30 GB usable, about 17 hours</li>++<li>80 GB usable, about 45 hours</li>++<li>180 GB usable, about 102 hours</li>++<li>380 GB usable, about 216 hours</li></ul><div class="card tip"><strong>Storage takeaway:</strong> 50 GB is test-only. 100 GB is okay for dev/alpha. <strong>200 GB</strong> is the first production-usable size. 400 GB is much better but still not archival. Long-term, push older VODs to object storage.</div>@@@ -31,11 +31,17 @@ assert.strictEqual(old.kind, 'live'); assert.strictEqual(old.name, 'AmberFieraGaconst fromMeta = _chatEventOf({ message_type: 'system', message: 'something else', metadata: { kind: 'live_link', url: 'https://openvibe.live/@iZoR_One' } });assert.strictEqual(fromMeta.name, 'iZoR_One', 'the name comes from the link when the text is not the usual one');--const rd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Second McDoanld\'s Run: Bug Fix Later” → https://openvibe.live/rundown/e/2891',++const rd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Second McDoanld\'s Run: Bug Fix Later” at https://openvibe.live/rundown/e/2891',metadata: { rundown_episode: 2891, url: 'https://openvibe.live/rundown/e/2891' } });assert.deepStrictEqual({ kind: rd.kind, name: rd.name, title: rd.title, url: rd.url },{ kind: 'rundown', name: 'goosely', title: "Second McDoanld's Run: Bug Fix Later", url: 'https://openvibe.live/rundown/e/2891' });++// Rows posted before the wording changed still read as the same card (the parser keeps the old separator as an escape).++const oldRd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Old episode” \u2192 https://openvibe.live/rundown/e/2890',++ metadata: { rundown_episode: 2890, url: 'https://openvibe.live/rundown/e/2890' } });++assert.deepStrictEqual({ kind: oldRd.kind, name: oldRd.name, title: oldRd.title, url: oldRd.url },++ { kind: 'rundown', name: 'goosely', title: 'Old episode', url: 'https://openvibe.live/rundown/e/2890' });++assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: https://evil.example/@x' }), null, 'never a link to another site');assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: javascript:alert(1)' }), null);assert.strictEqual(_chatEventOf({ message_type: 'system', message: '🚀 1 update shipped' }), null, 'a deploy row is not an event');@@@ -39,7 +39,7 @@ assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);await run({ anonId: 'zz', streamId: liveId }, '!beef');assert.match(sent.pop(), /Beefs are gone/);await run({ anonId: 'zy', streamId: liveId }, '!arena');--assert.match(sent.pop(), /^📰 Nova — latest episode: “The Fridge Files” · talks about Fridges → .*\/rundown\/nova$/, 'the streamer whose chat it is');++assert.match(sent.pop(), /^📰 Nova — latest episode: “The Fridge Files” · talks about Fridges at .*\/rundown\/nova$/, 'the streamer whose chat it is');await run({ anonId: 'zx', streamId: liveId }, '!arena quietguy');assert.match(sent.pop(), /Nothing on The Rundown about Quietguy yet/);await run({ anonId: 'zw', streamId: liveId }, '!arena nobody_here');@@@ -126,7 +126,7 @@ assert.strictEqual(ep.title, 'The Fridge Files');assert.strictEqual(ep.recap, 'goosely opened with French fridges.\n\nThen ranked.');assert.deepStrictEqual(JSON.parse(ep.chapters_json).map((c) => [c.title, Number(c.segment_id)]), [['Fridges', Number(segs[0].id)], ['Ranked', Number(segs[1].id)]], 'chapters name real segments');assert.strictEqual(announced.length, 1);--assert.match(announced[0].message, /^📰 goosely: “The Fridge Files” → .*\/rundown\/e\/\d+$/);++assert.match(announced[0].message, /^📰 goosely: “The Fridge Files” at .*\/rundown\/e\/\d+$/);assert.strictEqual((await db.get('SELECT done FROM rundown_progress WHERE stream_id = ?', [s1])).done, 1);const before = calls.length;await desk.tick();
@@ -66,7 +66,8 @@
@@ -66,7 +66,8 @@
.go:hover { transform: translateY(-2px) scale(1.03); }
.go:hover { transform: translateY(-2px) scale(1.03); }
.go:active { transform: scale(0.97); }
.go:active { transform: scale(0.97); }
.go:disabled { cursor: default; animation: none; opacity: 0.85; }
.go:disabled { cursor: default; animation: none; opacity: 0.85; }
- .go .arrow { display: inline-block; transition: transform var(--ov-dur-2, 0.2s); }
+ .go .arrow { display: inline-block; margin-left: 5px; vertical-align: -0.18em; transition: transform var(--ov-dur-2, 0.2s); }
+ .go .arrow svg { display: block; width: 15px; height: 15px; }
.go:hover .arrow { transform: translateX(4px); }
.go:hover .arrow { transform: translateX(4px); }
.go .rip { position: absolute; border-radius: 50%; background: rgba(255,255,255,0.5); transform: scale(0); animation: rip var(--ov-dur-4, 0.7s) var(--ov-ease-out, ease-out) forwards; pointer-events: none; }
.go .rip { position: absolute; border-radius: 50%; background: rgba(255,255,255,0.5); transform: scale(0); animation: rip var(--ov-dur-4, 0.7s) var(--ov-ease-out, ease-out) forwards; pointer-events: none; }
@keyframes sheen { to { background-position: -200% 0; } }
@keyframes sheen { to { background-position: -200% 0; } }
@@ -126,7 +127,7 @@
@@ -126,7 +127,7 @@
<div class="sorry rv" style="--d:8.2s">
<div class="sorry rv" style="--d:8.2s">
<h3 class="sorry-head">Sorry I'm being a bitch about this whole thing.</h3>
<h3 class="sorry-head">Sorry I'm being a bitch about this whole thing.</h3>
<p class="sorry-body">I don't actually want to lock you out. Press continue and the ban is gone — on every network and every device. Just be decent to me.</p>
<p class="sorry-body">I don't actually want to lock you out. Press continue and the ban is gone — on every network and every device. Just be decent to me.</p>
- <button class="go" id="go" type="button">♥ Continue <span class="arrow">→</span></button>
+ <button class="go" id="go" type="button">♥ Continue<span class="arrow"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3"/></svg></span></button>
<span class="go-hint">One press. That's it.</span>
<span class="go-hint">One press. That's it.</span>
</div>
</div>
<p class="fine rv" style="--d:8.6s"><span class="logo">OpenVibe.Live</span> · you will see this page every time you open the site, on every device and every network you use — until you press continue.</p>
<p class="fine rv" style="--d:8.6s"><span class="logo">OpenVibe.Live</span> · you will see this page every time you open the site, on every device and every network you use — until you press continue.</p>
@@ -136,6 +137,7 @@
@@ -136,6 +137,7 @@
// Continue: lift the ban server-side, then play the lift and go home.
// Continue: lift the ban server-side, then play the lift and go home.
(function () {
(function () {
var btn = document.getElementById('go');
var btn = document.getElementById('go');
+ var ARROW = '<span class="arrow"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M3.2 12h16.7M13.7 5.7l6.3 6.3l-6.3 6.3"/></svg></span>';
if (!btn) return;
if (!btn) return;
btn.addEventListener('click', async function (ev) {
btn.addEventListener('click', async function (ev) {
if (btn.disabled) return;
if (btn.disabled) return;
@@ -148,7 +150,7 @@
@@ -148,7 +150,7 @@
var j = await res.json();
var j = await res.json();
ok = !!(res.ok && j.ok); name = j.name || null;
ok = !!(res.ok && j.ok); name = j.name || null;
} catch (e) { ok = false; }
} catch (e) { ok = false; }
- if (!ok) { btn.disabled = false; btn.innerHTML = 'Try again <span class="arrow">→</span>'; return; }
+ if (!ok) { btn.disabled = false; btn.innerHTML = 'Try again' + ARROW; return; }
var reduced = (document.documentElement.getAttribute('data-ov-motion') === 'off');
var reduced = (document.documentElement.getAttribute('data-ov-motion') === 'off');
if (name) document.getElementById('welcome-line').textContent = name + ', the ban is lifted. Taking you to the site…';
if (name) document.getElementById('welcome-line').textContent = name + ', the ban is lifted. Taking you to the site…';
if (!reduced) {
if (!reduced) {
@@ -152,7 +152,7 @@
@@ -152,7 +152,7 @@
</summary>
</summary>
<div class="bc-app-guide-body">
<div class="bc-app-guide-body">
<ol>
<ol>
- <li>Open OBS → Settings → Stream</li>
+ <li>Open OBS, then Settings, then Stream</li>
<li>Set Service to <strong>Custom</strong></li>
<li>Set Service to <strong>Custom</strong></li>
<li>Paste the <strong>Server URL</strong> above</li>
<li>Paste the <strong>Server URL</strong> above</li>
<li>Paste the <strong>Stream Key</strong></li>
<li>Paste the <strong>Stream Key</strong></li>
@@ -202,8 +202,8 @@
@@ -202,8 +202,8 @@
<h4 style="margin:12px 0 8px">IRL Pro Step-by-Step</h4>
<h4 style="margin:12px 0 8px">IRL Pro Step-by-Step</h4>
<ol>
<ol>
- <li>Open <strong>IRL Pro</strong> → <strong>⚙️ Settings</strong> → <strong>Streaming</strong></li>
- <li>Select <strong>Platform</strong> → <strong>Custom RTMP</strong></li>
+ <li>Open <strong>IRL Pro</strong>, then <strong>⚙️ Settings</strong>, then <strong>Streaming</strong></li>
+ <li>Select <strong>Platform</strong>, then <strong>Custom RTMP</strong></li>
<li>Paste the <strong>Server URL</strong> and <strong>Stream Key</strong> from Option A above</li>
<li>Paste the <strong>Server URL</strong> and <strong>Stream Key</strong> from Option A above</li>
<li><strong>Leave Username and Password empty</strong></li>
<li><strong>Leave Username and Password empty</strong></li>
<li>Set <strong>Resolution</strong> to 720p, <strong>Bitrate</strong> to 2500–4000 kbps</li>
<li>Set <strong>Resolution</strong> to 720p, <strong>Bitrate</strong> to 2500–4000 kbps</li>
@@ -236,14 +236,14 @@
@@ -236,14 +236,14 @@
<div class="bc-app-guide-body">
<div class="bc-app-guide-body">
<p class="bc-app-guide-desc">Stream directly from a terminal — great for headless servers, Raspberry Pi, 3D printer cams, and automated pipelines.</p>
<p class="bc-app-guide-desc">Stream directly from a terminal — great for headless servers, Raspberry Pi, 3D printer cams, and automated pipelines.</p>
<div class="bc-code-block" style="margin-bottom:12px">
<div class="bc-code-block" style="margin-bottom:12px">
- <label><i class="fa-solid fa-video"></i> Webcam + Audio → RTMP</label>
+ <label><i class="fa-solid fa-video"></i> Webcam + Audio to RTMP</label>
<div class="bc-code-copy">
<div class="bc-code-copy">
<code id="bc-rtmp-ffmpeg-cam" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<code id="bc-rtmp-ffmpeg-cam" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-rtmp-ffmpeg-cam').textContent)"><i class="fa-solid fa-copy"></i></button>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-rtmp-ffmpeg-cam').textContent)"><i class="fa-solid fa-copy"></i></button>
</div>
</div>
</div>
</div>
<div class="bc-code-block" style="margin-bottom:12px">
<div class="bc-code-block" style="margin-bottom:12px">
- <label><i class="fa-solid fa-display"></i> Screen Capture → RTMP</label>
+ <label><i class="fa-solid fa-display"></i> Screen Capture to RTMP</label>
<div class="bc-code-copy">
<div class="bc-code-copy">
<code id="bc-rtmp-ffmpeg-screen" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<code id="bc-rtmp-ffmpeg-screen" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-rtmp-ffmpeg-screen').textContent)"><i class="fa-solid fa-copy"></i></button>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-rtmp-ffmpeg-screen').textContent)"><i class="fa-solid fa-copy"></i></button>
@@ -314,7 +314,7 @@
@@ -314,7 +314,7 @@
</div>
</div>
</div>
</div>
<div class="bc-code-block">
<div class="bc-code-block">
- <label><i class="fa-solid fa-display"></i> OBS Virtual Camera → JSMPEG</label>
+ <label><i class="fa-solid fa-display"></i> OBS Virtual Camera to JSMPEG</label>
<div class="bc-code-copy">
<div class="bc-code-copy">
<code id="bc-jsmpeg-cmd-obs" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<code id="bc-jsmpeg-cmd-obs" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-jsmpeg-cmd-obs').textContent)"><i class="fa-solid fa-copy"></i></button>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-jsmpeg-cmd-obs').textContent)"><i class="fa-solid fa-copy"></i></button>
@@ -403,7 +403,7 @@
@@ -403,7 +403,7 @@
<div class="bc-code-block" style="margin-bottom:8px">
<div class="bc-code-block" style="margin-bottom:8px">
<label>Use PulseAudio instead of ALSA</label>
<label>Use PulseAudio instead of ALSA</label>
<div class="bc-code-copy">
<div class="bc-code-copy">
- <code style="white-space:pre-wrap">Replace: -f alsa -i default → -f pulse -i default</code>
+ <code style="white-space:pre-wrap">Replace: -f alsa -i default with -f pulse -i default</code>
<button class="btn btn-small" onclick="copyToClipboard('-f pulse -i default')"><i class="fa-solid fa-copy"></i></button>
<button class="btn btn-small" onclick="copyToClipboard('-f pulse -i default')"><i class="fa-solid fa-copy"></i></button>
</div>
</div>
</div>
</div>
@@ -411,7 +411,7 @@
@@ -411,7 +411,7 @@
<div class="bc-code-block" style="margin-bottom:8px">
<div class="bc-code-block" style="margin-bottom:8px">
<label>OBS Virtual Camera</label>
<label>OBS Virtual Camera</label>
<div class="bc-code-copy">
<div class="bc-code-copy">
- <code style="white-space:pre-wrap">Start OBS → Tools → Start Virtual Camera → use /dev/video2 (or check v4l2-ctl --list-devices)</code>
+ <code style="white-space:pre-wrap">Start OBS, then Tools, then Start Virtual Camera, then use /dev/video2 (or check v4l2-ctl --list-devices)</code>
<button class="btn btn-small" onclick="copyToClipboard('v4l2-ctl --list-devices')"><i class="fa-solid fa-copy"></i></button>
<button class="btn btn-small" onclick="copyToClipboard('v4l2-ctl --list-devices')"><i class="fa-solid fa-copy"></i></button>
</div>
</div>
</div>
</div>
@@ -481,7 +481,7 @@
@@ -481,7 +481,7 @@
</div>
</div>
</div>
</div>
<div class="bc-code-block" style="margin-bottom:12px">
<div class="bc-code-block" style="margin-bottom:12px">
- <label>IP camera (RTSP) → JSMPEG</label>
+ <label>IP camera (RTSP) to JSMPEG</label>
<div class="bc-code-copy">
<div class="bc-code-copy">
<code id="bc-jsmpeg-cmd-ipcam" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<code id="bc-jsmpeg-cmd-ipcam" style="white-space:pre-wrap;word-break:break-all">loading...</code>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-jsmpeg-cmd-ipcam').textContent)"><i class="fa-solid fa-copy"></i></button>
<button class="btn btn-small" onclick="copyToClipboard(document.getElementById('bc-jsmpeg-cmd-ipcam').textContent)"><i class="fa-solid fa-copy"></i></button>
@@ -550,7 +550,7 @@ WantedBy=multi-user.target</code>
@@ -550,7 +550,7 @@ WantedBy=multi-user.target</code>
<h2><i class="fa-solid fa-desktop"></i> WebRTC via OBS (WHIP)</h2>
<h2><i class="fa-solid fa-desktop"></i> WebRTC via OBS (WHIP)</h2>
<p class="bc-instructions-intro">Your stream is created! Paste the info below into OBS Studio 30+ to start streaming with ultra-low latency via WebRTC. This is newer and faster than RTMP.</p>
<p class="bc-instructions-intro">Your stream is created! Paste the info below into OBS Studio 30+ to start streaming with ultra-low latency via WebRTC. This is newer and faster than RTMP.</p>
- <p class="muted" style="margin-top:8px"><i class="fa-solid fa-info-circle"></i> Note: on some Linux distributions, the system OBS package may disable WHIP even in OBS 30+. If you do not see the WHIP service in Settings → Stream, install OBS from Flatpak or AppImage instead of the distro package.</p>
+ <p class="muted" style="margin-top:8px"><i class="fa-solid fa-info-circle"></i> Note: on some Linux distributions, the system OBS package may disable WHIP even in OBS 30+. If you do not see the WHIP service in Settings, then Stream, install OBS from Flatpak or AppImage instead of the distro package.</p>
<div class="bc-code-block">
<div class="bc-code-block">
<label>WHIP Endpoint</label>
<label>WHIP Endpoint</label>
<div class="bc-code-copy">
<div class="bc-code-copy">
@@ -570,7 +570,7 @@ WantedBy=multi-user.target</code>
@@ -570,7 +570,7 @@ WantedBy=multi-user.target</code>
<div class="bc-instructions-steps">
<div class="bc-instructions-steps">
<h3>OBS 30+ Setup</h3>
<h3>OBS 30+ Setup</h3>
<ol>
<ol>
- <li>Open OBS → Settings → Stream</li>
+ <li>Open OBS, then Settings, then Stream</li>
<li>Set Service to <strong>WHIP</strong></li>
<li>Set Service to <strong>WHIP</strong></li>
<li>Paste the <strong>WHIP Endpoint</strong> URL above</li>
<li>Paste the <strong>WHIP Endpoint</strong> URL above</li>
<li>Paste the <strong>Bearer Token</strong> as the auth key</li>
<li>Paste the <strong>Bearer Token</strong> as the auth key</li>
@@ -366,7 +366,7 @@
@@ -366,7 +366,7 @@
<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) -->
<!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) -->
<div class="dash-card dash-card-wide" id="dash-card-aimoments">
<div class="dash-card dash-card-wide" id="dash-card-aimoments">
<h3 style="margin:0 0 6px"><i class="fa-solid fa-sparkles"></i> AI Moments of My Stream</h3>
<h3 style="margin:0 0 6px"><i class="fa-solid fa-sparkles"></i> AI Moments of My Stream</h3>
- <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home → AI Moments from my streams.</p>
+ <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them, and turn the AI off for your channel under Home, then AI Moments from my streams.</p>
<div id="dash-ai-clips" class="vod-grid"></div>
<div id="dash-ai-clips" class="vod-grid"></div>
</div>
</div>
@@ -504,7 +504,7 @@
@@ -504,7 +504,7 @@
<input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)">
<input type="checkbox" id="dash-ai-derivation" checked onchange="saveAiDerivationSetting(this)">
<span>Let OpenVibe's AI make Moments from my streams</span>
<span>Let OpenVibe's AI make Moments from my streams</span>
</label>
</label>
- <p class="muted" style="font-size:0.85rem;margin:0">Moments are auto-clips cut when chat reacts, frames the AI picks and posts as notes, and AI-written after-show recaps. They are always labelled as AI-made, never credited to you, and kept out of search. Turned off, the AI makes no new ones from your streams (your after-show report keeps its stats). Moments already made stay until you delete them under Content → AI Moments.</p>
+ <p class="muted" style="font-size:0.85rem;margin:0">Moments are auto-clips cut when chat reacts, frames the AI picks and posts as notes, and AI-written after-show recaps. They are always labelled as AI-made, never credited to you, and kept out of search. Turned off, the AI makes no new ones from your streams (your after-show report keeps its stats). Moments already made stay until you delete them under Content, then AI Moments.</p>
<span id="dash-ai-derivation-status" class="muted" style="font-size:0.82rem"></span>
<span id="dash-ai-derivation-status" class="muted" style="font-size:0.82rem"></span>
</div>
</div>
@@ -96,7 +96,7 @@
@@ -96,7 +96,7 @@
<h3>Base URL & Auth</h3>
<h3>Base URL & Auth</h3>
<p>Base URL: <code>https://openvibe.live</code> (replace with your instance).</p>
<p>Base URL: <code>https://openvibe.live</code> (replace with your instance).</p>
<p>Auth: <code>Authorization: Bearer <JWT></code> header. On WebSockets, the header on the upgrade request or the <code>token</code> of the first <code>join</code> message. A token in the URL or a cookie does not sign a socket in.</p>
<p>Auth: <code>Authorization: Bearer <JWT></code> header. On WebSockets, the header on the upgrade request or the <code>token</code> of the first <code>join</code> message. A token in the URL or a cookie does not sign a socket in.</p>
-<p>API tokens: <code>hbt_</code> prefix, scoped permissions, created from Dashboard → API Tokens.</p>
+<p>API tokens: <code>hbt_</code> prefix, scoped permissions, created from Dashboard, then API Tokens.</p>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
@@ -145,9 +145,9 @@
@@ -145,9 +145,9 @@
<h3>Admin Proxy Architecture</h3>
<h3>Admin Proxy Architecture</h3>
<p>openvibe.network admin panel proxies requests to OpenVibe.Live:</p>
<p>openvibe.network admin panel proxies requests to OpenVibe.Live:</p>
-<pre><code>openvibe.network /api/admin/streamer/* → OpenVibe.Live /api/admin/*
-openvibe.network /api/admin/streamer-tts/* → OpenVibe.Live /api/tts/*
-openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code></pre>
+<pre><code>openvibe.network /api/admin/streamer/* maps to OpenVibe.Live /api/admin/*
+openvibe.network /api/admin/streamer-tts/* maps to OpenVibe.Live /api/tts/*
+openvibe.network /api/admin/streamer-mod/* maps to OpenVibe.Live /api/mod/*</code></pre>
<p>Server-to-server calls carry a short-lived service token from openvibe.network, scoped to the one capability each route performs.</p>
<p>Server-to-server calls carry a short-lived service token from openvibe.network, scoped to the one capability each route performs.</p>
</div>
</div>
@@ -171,8 +171,8 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
@@ -171,8 +171,8 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
<table class="doc-table" tabindex="0">
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
-<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 → openvibe.network OAuth authorize</td></tr>
-<tr><td>GET</td><td>/api/auth/callback?code&state</td><td>No</td><td>OAuth code→token exchange (PKCE); sets cookies</td></tr>
+<tr><td>GET</td><td>/api/auth/sso/login</td><td>No</td><td>302 redirects to openvibe.network OAuth authorize</td></tr>
+<tr><td>GET</td><td>/api/auth/callback?code&state</td><td>No</td><td>OAuth code to token exchange (PKCE); sets cookies</td></tr>
<tr><td>GET</td><td>/api/auth/health</td><td>No</td><td>OAuth client health; 503 only when the Network refuses this site's client</td></tr>
<tr><td>GET</td><td>/api/auth/health</td><td>No</td><td>OAuth client health; 503 only when the Network refuses this site's client</td></tr>
<tr><td>POST</td><td>/api/auth/refresh</td><td>Cookie</td><td>Refresh access token via httpOnly <code>ov_refresh</code> cookie</td></tr>
<tr><td>POST</td><td>/api/auth/refresh</td><td>Cookie</td><td>Refresh access token via httpOnly <code>ov_refresh</code> cookie</td></tr>
<tr><td>POST</td><td>/api/auth/logout</td><td>No</td><td>Clear auth cookies</td></tr>
<tr><td>POST</td><td>/api/auth/logout</td><td>No</td><td>Clear auth cookies</td></tr>
@@ -189,11 +189,11 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
@@ -189,11 +189,11 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
</table>
</table>
<h3>API Tokens (<code>hbt_</code> prefix)</h3>
<h3>API Tokens (<code>hbt_</code> prefix)</h3>
-<p>Long-lived, scoped tokens for bots/integrations. Create them from Dashboard → API Tokens (max 10 active). The token string is shown once. Use as <code>Authorization: Bearer hbt_xxxxxxxxxxxx</code>.</p>
+<p>Long-lived, scoped tokens for bots/integrations. Create them from Dashboard, then API Tokens (max 10 active). The token string is shown once. Use as <code>Authorization: Bearer hbt_xxxxxxxxxxxx</code>.</p>
<table class="doc-table" tabindex="0">
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
-<tr><td>POST</td><td>/api/auth/tokens</td><td>Create <code>{ label, scopes[], expiresAt? }</code> → <code>{ id, token, ... }</code> (JWT auth only, not an hbt_ token)</td></tr>
+<tr><td>POST</td><td>/api/auth/tokens</td><td>Create <code>{ label, scopes[], expiresAt? }</code> returns <code>{ id, token, ... }</code> (JWT auth only, not an hbt_ token)</td></tr>
<tr><td>GET</td><td>/api/auth/tokens</td><td>List tokens + <code>valid_scopes</code>, <code>scope_definitions</code>, <code>token_presets</code></td></tr>
<tr><td>GET</td><td>/api/auth/tokens</td><td>List tokens + <code>valid_scopes</code>, <code>scope_definitions</code>, <code>token_presets</code></td></tr>
<tr><td>DELETE</td><td>/api/auth/tokens/:id</td><td>Revoke a token</td></tr>
<tr><td>DELETE</td><td>/api/auth/tokens/:id</td><td>Revoke a token</td></tr>
</tbody>
</tbody>
@@ -201,7 +201,7 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
@@ -201,7 +201,7 @@ openvibe.network /api/admin/streamer-mod/* → OpenVibe.Live /api/mod/*</code>
<p><strong>Scopes:</strong> <code>chat</code>, <code>read</code>, <code>stream</code>, <code>control</code>, <code>vibe_coding_publish</code>. <strong>Presets:</strong> chat-bot (<code>chat,read</code>), stream-controller (<code>read,stream,control</code>), vibe-coding-publisher (<code>read,vibe_coding_publish</code>).</p>
<p><strong>Scopes:</strong> <code>chat</code>, <code>read</code>, <code>stream</code>, <code>control</code>, <code>vibe_coding_publish</code>. <strong>Presets:</strong> chat-bot (<code>chat,read</code>), stream-controller (<code>read,stream,control</code>), vibe-coding-publisher (<code>read,vibe_coding_publish</code>).</p>
<h3>Example: use an API token</h3>
<h3>Example: use an API token</h3>
-<pre><code>// Create the token once from Dashboard → API Tokens (scopes: chat, read),
+<pre><code>// Create the token once from Dashboard, then API Tokens (scopes: chat, read),
// then use it directly — no login flow needed.
// then use it directly — no login flow needed.
const TOKEN = 'hbt_xxxxxxxxxxxxxxxx';
const TOKEN = 'hbt_xxxxxxxxxxxxxxxx';
@@ -226,7 +226,7 @@ console.log('Acting as:', me.user.username);
@@ -226,7 +226,7 @@ console.log('Acting as:', me.user.username);
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
<tr><td>GET</td><td>/api/streams/managed</td><td>Yes</td><td>My slots + slot limit</td></tr>
<tr><td>GET</td><td>/api/streams/managed</td><td>Yes</td><td>My slots + slot limit</td></tr>
-<tr><td>POST</td><td>/api/streams/managed</td><td>Yes</td><td>Create a slot → unique <code>stream_key</code>. Body: <code>title, description, category, protocol, is_nsfw, slug, streaming_method</code></td></tr>
+<tr><td>POST</td><td>/api/streams/managed</td><td>Yes</td><td>Create a slot; it returns a unique <code>stream_key</code>. Body: <code>title, description, category, protocol, is_nsfw, slug, streaming_method</code></td></tr>
<tr><td>PUT</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Update slot + slot settings (see below)</td></tr>
<tr><td>PUT</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Update slot + slot settings (see below)</td></tr>
<tr><td>DELETE</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Delete slot (409 if live)</td></tr>
<tr><td>DELETE</td><td>/api/streams/managed/:id</td><td>Owner</td><td>Delete slot (409 if live)</td></tr>
<tr><td>POST</td><td>/api/streams/managed/:id/regenerate-key</td><td>Owner</td><td>New slot stream key</td></tr>
<tr><td>POST</td><td>/api/streams/managed/:id/regenerate-key</td><td>Owner</td><td>New slot stream key</td></tr>
@@ -238,7 +238,7 @@ console.log('Acting as:', me.user.username);
@@ -238,7 +238,7 @@ console.log('Acting as:', me.user.username);
<table class="doc-table" tabindex="0">
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
-<tr><td>POST</td><td>/api/streams</td><td>Yes</td><td><strong>Go live</strong> from a slot. Body: <code>managed_stream_id</code> (required) + optional <code>title, category, tags, is_nsfw</code> → <code>{ stream, endpoint }</code></td></tr>
+<tr><td>POST</td><td>/api/streams</td><td>Yes</td><td><strong>Go live</strong> from a slot. Body: <code>managed_stream_id</code> (required) + optional <code>title, category, tags, is_nsfw</code> returns <code>{ stream, endpoint }</code></td></tr>
<tr><td>GET</td><td>/api/streams/:id/endpoint</td><td>Owner</td><td>Ingest endpoint + <code>stream_key</code> for the session's protocol</td></tr>
<tr><td>GET</td><td>/api/streams/:id/endpoint</td><td>Owner</td><td>Ingest endpoint + <code>stream_key</code> for the session's protocol</td></tr>
<tr><td>PUT</td><td>/api/streams/:id</td><td>Owner</td><td>Update live session (title/description/category/tags)</td></tr>
<tr><td>PUT</td><td>/api/streams/:id</td><td>Owner</td><td>Update live session (title/description/category/tags)</td></tr>
<tr><td>DELETE</td><td>/api/streams/:id</td><td>Owner</td><td>End the session</td></tr>
<tr><td>DELETE</td><td>/api/streams/:id</td><td>Owner</td><td>End the session</td></tr>
@@ -325,32 +325,32 @@ const { answer, location } = await live.whip.publish(live.whip.url(slot.stream_k
@@ -325,32 +325,32 @@ const { answer, location } = await live.whip.publish(live.whip.url(slot.stream_k
<table class="doc-table" tabindex="0">
<table class="doc-table" tabindex="0">
<thead><tr><th>Type</th><th>Dir</th><th>Key Fields</th><th>Description</th></tr></thead>
<thead><tr><th>Type</th><th>Dir</th><th>Key Fields</th><th>Description</th></tr></thead>
<tbody>
<tbody>
-<tr><td>welcome</td><td>S→C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr>
-<tr><td>watch</td><td>C→S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>
-<tr><td>broadcaster-ready</td><td>S→Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>
-<tr><td>watch-queued</td><td>S→Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>
-<tr><td>sfu-viewer-ready</td><td>S→Viewer</td><td>rtpCapabilities, producers</td><td>Server has live SFU producers ready for consumption</td></tr>
-<tr><td>sfu-viewer-create-transport</td><td>C→S</td><td>—</td><td>Viewer asks the server to create an SFU recv transport</td></tr>
-<tr><td>sfu-viewer-transport-created</td><td>S→Viewer</td><td>id, iceParameters, iceCandidates, dtlsParameters, iceServers</td><td>Viewer recv transport parameters</td></tr>
-<tr><td>sfu-viewer-connect-transport</td><td>C→S</td><td>transportId, dtlsParameters</td><td>Connect the viewer recv transport</td></tr>
-<tr><td>sfu-viewer-transport-connected</td><td>S→Viewer</td><td>transportId</td><td>Viewer recv transport connected</td></tr>
-<tr><td>sfu-viewer-consume</td><td>C→S</td><td>transportId, producerId, rtpCapabilities</td><td>Create an SFU consumer for a live producer</td></tr>
-<tr><td>sfu-viewer-consumed</td><td>S→Viewer</td><td>id, producerId, kind, rtpParameters</td><td>Consumer parameters returned to mediasoup-client</td></tr>
-<tr><td>sfu-source-unavailable</td><td>S→Viewer</td><td>reason</td><td>Live source exists, but it is not currently healthy for viewers</td></tr>
-<tr><td>sfu-get-capabilities</td><td>C→S</td><td>—</td><td>Get Mediasoup RTP capabilities</td></tr>
-<tr><td>sfu-create-transport</td><td>C→S</td><td>direction: send|recv</td><td>Create WebRTC transport</td></tr>
-<tr><td>sfu-connect-transport</td><td>C→S</td><td>transportId, dtlsParameters</td><td>Connect DTLS</td></tr>
-<tr><td>sfu-produce</td><td>C→S</td><td>transportId, kind, rtpParameters</td><td>Start producing audio/video</td></tr>
-<tr><td>sfu-stop-produce</td><td>C→S</td><td>producerId</td><td>Stop a producer</td></tr>
-<tr><td>viewer-count</td><td>S→All</td><td>count</td><td>Updated viewer count broadcast</td></tr>
-<tr><td>stream-ended</td><td>S→Viewers</td><td>—</td><td>Stream has ended</td></tr>
+<tr><td>welcome</td><td>S to C</td><td>peerId, role, streamId, viewerCount, iceServers</td><td>Sent immediately on connect</td></tr>
+<tr><td>watch</td><td>C to S</td><td>—</td><td>Viewer requests to start watching (triggers SFU consumer)</td></tr>
+<tr><td>broadcaster-ready</td><td>S to Viewer</td><td>peerId</td><td>Broadcaster or SFU source is live, safe to send/refresh <code>watch</code></td></tr>
+<tr><td>watch-queued</td><td>S to Viewer</td><td>reason, detail</td><td>Viewer is queued while the live source warms up or reconnects</td></tr>
+<tr><td>sfu-viewer-ready</td><td>S to Viewer</td><td>rtpCapabilities, producers</td><td>Server has live SFU producers ready for consumption</td></tr>
+<tr><td>sfu-viewer-create-transport</td><td>C to S</td><td>—</td><td>Viewer asks the server to create an SFU recv transport</td></tr>
+<tr><td>sfu-viewer-transport-created</td><td>S to Viewer</td><td>id, iceParameters, iceCandidates, dtlsParameters, iceServers</td><td>Viewer recv transport parameters</td></tr>
+<tr><td>sfu-viewer-connect-transport</td><td>C to S</td><td>transportId, dtlsParameters</td><td>Connect the viewer recv transport</td></tr>
+<tr><td>sfu-viewer-transport-connected</td><td>S to Viewer</td><td>transportId</td><td>Viewer recv transport connected</td></tr>
+<tr><td>sfu-viewer-consume</td><td>C to S</td><td>transportId, producerId, rtpCapabilities</td><td>Create an SFU consumer for a live producer</td></tr>
+<tr><td>sfu-viewer-consumed</td><td>S to Viewer</td><td>id, producerId, kind, rtpParameters</td><td>Consumer parameters returned to mediasoup-client</td></tr>
+<tr><td>sfu-source-unavailable</td><td>S to Viewer</td><td>reason</td><td>Live source exists, but it is not currently healthy for viewers</td></tr>
+<tr><td>sfu-get-capabilities</td><td>C to S</td><td>—</td><td>Get Mediasoup RTP capabilities</td></tr>
+<tr><td>sfu-create-transport</td><td>C to S</td><td>direction: send|recv</td><td>Create WebRTC transport</td></tr>
+<tr><td>sfu-connect-transport</td><td>C to S</td><td>transportId, dtlsParameters</td><td>Connect DTLS</td></tr>
+<tr><td>sfu-produce</td><td>C to S</td><td>transportId, kind, rtpParameters</td><td>Start producing audio/video</td></tr>
+<tr><td>sfu-stop-produce</td><td>C to S</td><td>producerId</td><td>Stop a producer</td></tr>
+<tr><td>viewer-count</td><td>S to All</td><td>count</td><td>Updated viewer count broadcast</td></tr>
+<tr><td>stream-ended</td><td>S to Viewers</td><td>—</td><td>Stream has ended</td></tr>
</tbody>
</tbody>
</table>
</table>
<p class="muted" style="margin-top:10px">Viewer media is delivered exclusively through the Mediasoup SFU. There is no direct peer-to-peer relay: legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> messages are not supported.</p>
<p class="muted" style="margin-top:10px">Viewer media is delivered exclusively through the Mediasoup SFU. There is no direct peer-to-peer relay: legacy <code>offer</code> / <code>answer</code> / <code>ice-candidate</code> messages are not supported.</p>
<h3>RTMP Ingest (OBS)</h3>
<h3>RTMP Ingest (OBS)</h3>
<pre><code>Server: rtmp://openvibe.live/live
<pre><code>Server: rtmp://openvibe.live/live
-Key: YOUR_STREAM_KEY (get from Dashboard → Stream Key)
+Key: YOUR_STREAM_KEY (get from Dashboard, then Stream Key)
# Playback proxy for browser
# Playback proxy for browser
GET /api/streams/rtmp-proxy/:streamId.flv
GET /api/streams/rtmp-proxy/:streamId.flv
@@ -368,7 +368,7 @@ GET /api/streams/rtmp-proxy/:streamId.flv
@@ -368,7 +368,7 @@ GET /api/streams/rtmp-proxy/:streamId.flv
<p>Connect: <code>wss://openvibe.live/ws/chat?stream=STREAM_ID</code> with an <code>Authorization: Bearer <token></code> header (a JWT or an <code>hbt_</code> API token), then send <code>{ "type": "join", "streamId": STREAM_ID }</code>. A client that cannot set headers (a browser) puts the token in that join instead: <code>{ "type": "join", "streamId": STREAM_ID, "token": "…" }</code>.</p>
<p>Connect: <code>wss://openvibe.live/ws/chat?stream=STREAM_ID</code> with an <code>Authorization: Bearer <token></code> header (a JWT or an <code>hbt_</code> API token), then send <code>{ "type": "join", "streamId": STREAM_ID }</code>. A client that cannot set headers (a browser) puts the token in that join instead: <code>{ "type": "join", "streamId": STREAM_ID, "token": "…" }</code>.</p>
<p>Omit <code>stream</code> for global chat; omit the token to connect anonymously (assigned an anonXXXXX identity). A token in the URL does not sign the socket in: URLs end up in logs.</p>
<p>Omit <code>stream</code> for global chat; omit the token to connect anonymously (assigned an anonXXXXX identity). A token in the URL does not sign the socket in: URLs end up in logs.</p>
-<h3>Client → Server Messages</h3>
+<h3>Client to Server Messages</h3>
<table class="doc-table" tabindex="0">
<table class="doc-table" tabindex="0">
<thead><tr><th>Type</th><th>Payload</th><th>Description</th></tr></thead>
<thead><tr><th>Type</th><th>Payload</th><th>Description</th></tr></thead>
<tbody>
<tbody>
@@ -380,7 +380,7 @@ GET /api/streams/rtmp-proxy/:streamId.flv
@@ -380,7 +380,7 @@ GET /api/streams/rtmp-proxy/:streamId.flv
</tbody>
</tbody>
</table>
</table>
-<h3>Server → Client Messages</h3>
+<h3>Server to Client Messages</h3>
<table class="doc-table" tabindex="0">
<table class="doc-table" tabindex="0">
<thead><tr><th>Type</th><th>Key Fields</th><th>Description</th></tr></thead>
<thead><tr><th>Type</th><th>Key Fields</th><th>Description</th></tr></thead>
<tbody>
<tbody>
@@ -526,8 +526,8 @@ while True:
@@ -526,8 +526,8 @@ while True:
<tbody>
<tbody>
<tr><td>GET</td><td>/api/funds/balance</td><td>Yes</td><td>Both balances + USD cashout values (<code>usd_value = cashout_balance / 100</code>)</td></tr>
<tr><td>GET</td><td>/api/funds/balance</td><td>Yes</td><td>Both balances + USD cashout values (<code>usd_value = cashout_balance / 100</code>)</td></tr>
<tr><td>POST</td><td>/api/funds/donate</td><td>Yes</td><td>Donate spendable bucks to a streamer; credits their cashout balance + advances a goal. Body: <code>amount, streamer_id|stream_id, message?, goal_id?</code></td></tr>
<tr><td>POST</td><td>/api/funds/donate</td><td>Yes</td><td>Donate spendable bucks to a streamer; credits their cashout balance + advances a goal. Body: <code>amount, streamer_id|stream_id, message?, goal_id?</code></td></tr>
-<tr><td>POST</td><td>/api/funds/cashout</td><td>Yes</td><td>Request cashout → escrow. Body: <code>amount</code> (≥ 500), <code>paypal_email</code></td></tr>
-<tr><td>POST</td><td>/api/funds/recycle</td><td>Yes</td><td>Move cashout balance → spendable (re-donate instead of cashing out)</td></tr>
+<tr><td>POST</td><td>/api/funds/cashout</td><td>Yes</td><td>Request cashout to escrow. Body: <code>amount</code> (≥ 500), <code>paypal_email</code></td></tr>
+<tr><td>POST</td><td>/api/funds/recycle</td><td>Yes</td><td>Move cashout balance to spendable (re-donate instead of cashing out)</td></tr>
<tr><td>GET</td><td>/api/funds/history</td><td>Yes</td><td>Transaction history (<code>?limit</code> ≤ 200)</td></tr>
<tr><td>GET</td><td>/api/funds/history</td><td>Yes</td><td>Transaction history (<code>?limit</code> ≤ 200)</td></tr>
<tr><td>GET</td><td>/api/funds/leaderboard/:streamId</td><td>No</td><td>Top donors for a stream</td></tr>
<tr><td>GET</td><td>/api/funds/leaderboard/:streamId</td><td>No</td><td>Top donors for a stream</td></tr>
<tr><td>GET</td><td>/api/funds/goals/:userId</td><td>No</td><td>Public goal widget set (active + recently reached)</td></tr>
<tr><td>GET</td><td>/api/funds/goals/:userId</td><td>No</td><td>Public goal widget set (active + recently reached)</td></tr>
@@ -544,14 +544,14 @@ while True:
@@ -544,14 +544,14 @@ while True:
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
<tr><td>GET</td><td>/api/payments/config</td><td>No</td><td>Live providers + pricing: <code>packages</code>, <code>cashoutBucksPerUsd</code> (100), <code>minPurchaseBucks</code>, <code>subPriceUsd</code></td></tr>
<tr><td>GET</td><td>/api/payments/config</td><td>No</td><td>Live providers + pricing: <code>packages</code>, <code>cashoutBucksPerUsd</code> (100), <code>minPurchaseBucks</code>, <code>subPriceUsd</code></td></tr>
-<tr><td>POST</td><td>/api/payments/bucks/checkout</td><td>Yes</td><td>Buy bucks (USD derived from volume tiers). Body: <code>provider, bucks</code> → <code>{ url }</code> hosted checkout</td></tr>
+<tr><td>POST</td><td>/api/payments/bucks/checkout</td><td>Yes</td><td>Buy bucks (USD derived from volume tiers). Body: <code>provider, bucks</code> returns <code>{ url }</code> hosted checkout</td></tr>
<tr><td>POST</td><td>/api/payments/subscribe</td><td>Yes</td><td>Subscribe to a channel. Body: <code>provider</code> (<code>stripe</code>|<code>bucks</code>), <code>streamer</code></td></tr>
<tr><td>POST</td><td>/api/payments/subscribe</td><td>Yes</td><td>Subscribe to a channel. Body: <code>provider</code> (<code>stripe</code>|<code>bucks</code>), <code>streamer</code></td></tr>
<tr><td>GET</td><td>/api/payments/subscriptions/mine · /channel/:username</td><td>Yes/Opt</td><td>My subs / am-I-subscribed + count + price</td></tr>
<tr><td>GET</td><td>/api/payments/subscriptions/mine · /channel/:username</td><td>Yes/Opt</td><td>My subs / am-I-subscribed + count + price</td></tr>
<tr><td>POST</td><td>/api/payments/subscriptions/:id/cancel</td><td>Yes</td><td>Cancel (Stripe at period end; bucks immediate)</td></tr>
<tr><td>POST</td><td>/api/payments/subscriptions/:id/cancel</td><td>Yes</td><td>Cancel (Stripe at period end; bucks immediate)</td></tr>
<tr><td>POST/ALL</td><td>/api/payments/webhook/{stripe,paypal,ccbill,crypto}</td><td>No</td><td><code>410 Gone</code> with <code>webhook_url</code>: providers deliver to OpenVibe.Billing (<code>https://billing.openvibe.network/webhooks/{stripe,paypal,ccbill,nowpayments}</code>)</td></tr>
<tr><td>POST/ALL</td><td>/api/payments/webhook/{stripe,paypal,ccbill,crypto}</td><td>No</td><td><code>410 Gone</code> with <code>webhook_url</code>: providers deliver to OpenVibe.Billing (<code>https://billing.openvibe.network/webhooks/{stripe,paypal,ccbill,nowpayments}</code>)</td></tr>
</tbody>
</tbody>
</table>
</table>
-<p><strong>Packages</strong>: <code>100, 500, 1000, 2500, 5000, 10000, 25000</code> bucks. <strong>Per-buck price tiers</strong>: ≥25000→$0.0110, ≥10000→$0.0115, ≥5000→$0.0120, ≥2500→$0.0124, ≥1000→$0.0130, ≥500→$0.0140, else $0.0150. Streamers keep <code>sub_streamer_share_pct</code> (default 70%) of sub value into their cashout balance.</p>
+<p><strong>Packages</strong>: <code>100, 500, 1000, 2500, 5000, 10000, 25000</code> bucks. <strong>Per-buck price tiers</strong>: ≥25000: $0.0110, ≥10000: $0.0115, ≥5000: $0.0120, ≥2500: $0.0124, ≥1000: $0.0130, ≥500: $0.0140, else $0.0150. Streamers keep <code>sub_streamer_share_pct</code> (default 70%) of sub value into their cashout balance.</p>
<h3>OpenCoins / Coins — <code>/api/coins</code></h3>
<h3>OpenCoins / Coins — <code>/api/coins</code></h3>
<p>Free currencies. <strong>GOLD</strong> is a global wallet (games, cosmetics, media requests). <strong>Channel points ("OpenCoins")</strong> are per-streamer loyalty points earned by watching/chatting a channel and spent on that streamer's rewards. Defaults: watch = 10/5min, chat = 5 (max 1/min), follow = 50 once; not earnable on your own stream.</p>
<p>Free currencies. <strong>GOLD</strong> is a global wallet (games, cosmetics, media requests). <strong>Channel points ("OpenCoins")</strong> are per-streamer loyalty points earned by watching/chatting a channel and spent on that streamer's rewards. Defaults: watch = 10/5min, chat = 5 (max 1/min), follow = 50 once; not earnable on your own stream.</p>
@@ -559,7 +559,7 @@ while True:
@@ -559,7 +559,7 @@ while True:
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
<tr><td>GET</td><td>/api/coins/balance · /channel-balance</td><td>Yes</td><td>Global GOLD / per-streamer points (<code>?streamerId</code>)</td></tr>
<tr><td>GET</td><td>/api/coins/balance · /channel-balance</td><td>Yes</td><td>Global GOLD / per-streamer points (<code>?streamerId</code>)</td></tr>
-<tr><td>POST</td><td>/api/coins/heartbeat · /bonus</td><td>Yes</td><td>Watch-time tick / claim bonus game → earn points (<code>streamId</code>)</td></tr>
+<tr><td>POST</td><td>/api/coins/heartbeat · /bonus</td><td>Yes</td><td>Watch-time tick / claim bonus game, then earn points (<code>streamId</code>)</td></tr>
<tr><td>GET</td><td>/api/coins/rewards/:userId · /config/:userId</td><td>No</td><td>A streamer's rewards + points config</td></tr>
<tr><td>GET</td><td>/api/coins/rewards/:userId · /config/:userId</td><td>No</td><td>A streamer's rewards + points config</td></tr>
<tr><td>PUT</td><td>/api/coins/config</td><td>Yes</td><td>Configure my channel points (name, icon, watch/game rates)</td></tr>
<tr><td>PUT</td><td>/api/coins/config</td><td>Yes</td><td>Configure my channel points (name, icon, watch/game rates)</td></tr>
<tr><td>POST / PUT / DELETE</td><td>/api/coins/rewards[/:id]</td><td>Yes</td><td>Manage my rewards (<code>title, cost, cooldown_seconds, max_per_stream, requires_input</code>)</td></tr>
<tr><td>POST / PUT / DELETE</td><td>/api/coins/rewards[/:id]</td><td>Yes</td><td>Manage my rewards (<code>title, cost, cooldown_seconds, max_per_stream, requires_input</code>)</td></tr>
@@ -878,7 +878,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -878,7 +878,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<table class="doc-table" tabindex="0">
<table class="doc-table" tabindex="0">
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<thead><tr><th>Method</th><th>Path</th><th>Auth</th><th>Notes</th></tr></thead>
<tbody>
<tbody>
-<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies, newest first (<code>type</code> = <code>vod|clip</code>, <code>?limit,offset</code>) → <code>{ comments, total, thread? }</code>; <code>thread.url</code> is the same thread on OpenVibe.Community (public and unlisted items)</td></tr>
+<tr><td>GET</td><td>/api/comments/:type/:id</td><td>Opt</td><td>List comments + replies, newest first (<code>type</code> = <code>vod|clip</code>, <code>?limit,offset</code>) returns <code>{ comments, total, thread? }</code>; <code>thread.url</code> is the same thread on OpenVibe.Community (public and unlisted items)</td></tr>
<tr><td>GET</td><td>/api/comments/:commentId/replies</td><td>Opt</td><td>All replies of a comment</td></tr>
<tr><td>GET</td><td>/api/comments/:commentId/replies</td><td>Opt</td><td>All replies of a comment</td></tr>
<tr><td>POST</td><td>/api/comments/:type/:id</td><td>Yes</td><td>Add a comment/reply (<code>message</code>, <code>parent_id?</code>)</td></tr>
<tr><td>POST</td><td>/api/comments/:type/:id</td><td>Yes</td><td>Add a comment/reply (<code>message</code>, <code>parent_id?</code>)</td></tr>
<tr><td>PUT</td><td>/api/comments/:commentId</td><td>Author</td><td>Edit your own comment</td></tr>
<tr><td>PUT</td><td>/api/comments/:commentId</td><td>Author</td><td>Edit your own comment</td></tr>
@@ -893,7 +893,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
@@ -893,7 +893,7 @@ https://www.101soundboards.com/sounds/12345 150p 2</code></pre>
<tbody>
<tbody>
<tr><td>GET</td><td>/api/pastes</td><td>Opt</td><td>List public pastes (<code>?limit,offset,type,username,search,sort</code>)</td></tr>
<tr><td>GET</td><td>/api/pastes</td><td>Opt</td><td>List public pastes (<code>?limit,offset,type,username,search,sort</code>)</td></tr>
<tr><td>GET</td><td>/api/pastes/:slug · /:slug/raw</td><td>Opt/No</td><td>Get a paste / raw content</td></tr>
<tr><td>GET</td><td>/api/pastes/:slug · /:slug/raw</td><td>Opt/No</td><td>Get a paste / raw content</td></tr>
-<tr><td>POST</td><td>/api/pastes</td><td>Opt</td><td>Create text paste (<code>content</code> + <code>title, language, visibility</code>); anon allowed if enabled → <code>{ paste, url }</code></td></tr>
+<tr><td>POST</td><td>/api/pastes</td><td>Opt</td><td>Create text paste (<code>content</code> + <code>title, language, visibility</code>); anon allowed if enabled returns <code>{ paste, url }</code></td></tr>
<tr><td>POST</td><td>/api/pastes/screenshot</td><td>Opt</td><td>Upload an image paste</td></tr>
<tr><td>POST</td><td>/api/pastes/screenshot</td><td>Opt</td><td>Upload an image paste</td></tr>
<tr><td>PUT / DELETE</td><td>/api/pastes/:slug</td><td>Owner/staff</td><td>Edit / delete a paste</td></tr>
<tr><td>PUT / DELETE</td><td>/api/pastes/:slug</td><td>Owner/staff</td><td>Edit / delete a paste</td></tr>
</tbody>
</tbody>
@@ -79,7 +79,7 @@
@@ -79,7 +79,7 @@
<span title="Live transcript"><i class="fa-solid fa-ear-listen"></i> heard ${st.senses.heard_lines ? `${st.senses.heard_lines} lines, latest ${ago(st.senses.heard_age_ms)}` : (st.timeline_enabled ? 'nothing recent' : 'off')}</span>
<span title="Live transcript"><i class="fa-solid fa-ear-listen"></i> heard ${st.senses.heard_lines ? `${st.senses.heard_lines} lines, latest ${ago(st.senses.heard_age_ms)}` : (st.timeline_enabled ? 'nothing recent' : 'off')}</span>
<span title="Latest screen analysis"><i class="fa-solid fa-eye"></i> seen ${st.senses.seen_age_ms != null ? ago(st.senses.seen_age_ms) : 'never'}${st.senses.seen_text ? ` — ${esc(st.senses.seen_text.slice(0, 90))}` : ''}</span>
<span title="Latest screen analysis"><i class="fa-solid fa-eye"></i> seen ${st.senses.seen_age_ms != null ? ago(st.senses.seen_age_ms) : 'never'}${st.senses.seen_text ? ` — ${esc(st.senses.seen_text.slice(0, 90))}` : ''}</span>
<span title="Cached prompt prefix"><i class="fa-solid fa-bolt"></i> context ${st.senses.stable_prefix_chars ? `${Math.round(st.senses.stable_prefix_chars / 4)} tokens cached` : 'building'}</span>
<span title="Cached prompt prefix"><i class="fa-solid fa-bolt"></i> context ${st.senses.stable_prefix_chars ? `${Math.round(st.senses.stable_prefix_chars / 4)} tokens cached` : 'building'}</span>
- <span><i class="fa-solid fa-clock"></i> next pass ${st.next_tick_in_ms != null ? `in ${Math.round(st.next_tick_in_ms / 1000)}s` : '—'} · ${st.lines_last_minute}/min · bot↔bot ${Math.round((st.bot_share || 0) * 100)}%</span>`;
+ <span><i class="fa-solid fa-clock"></i> next pass ${st.next_tick_in_ms != null ? `in ${Math.round(st.next_tick_in_ms / 1000)}s` : '—'} · ${st.lines_last_minute}/min · bot-to-bot ${Math.round((st.bot_share || 0) * 100)}%</span>`;
}
}
if (b) _setUsageMeter(b, st);
if (b) _setUsageMeter(b, st);
}
}
@@ -162,7 +162,7 @@
@@ -162,7 +162,7 @@
function logRow(r) {
function logRow(r) {
const icon = { line: 'fa-comment', tick: 'fa-clapperboard', skip: 'fa-forward', mention: 'fa-bullhorn', fold: 'fa-brain', vision: 'fa-eye', degrade: 'fa-gauge', pause: 'fa-pause', error: 'fa-triangle-exclamation', info: 'fa-circle-info' }[r.event] || 'fa-circle';
const icon = { line: 'fa-comment', tick: 'fa-clapperboard', skip: 'fa-forward', mention: 'fa-bullhorn', fold: 'fa-brain', vision: 'fa-eye', degrade: 'fa-gauge', pause: 'fa-pause', error: 'fa-triangle-exclamation', info: 'fa-circle-info' }[r.event] || 'fa-circle';
const cost = r.cost_usd != null ? `<span class="muted" title="${r.tokens_in || 0} in (${r.tokens_cached || 0} cached) / ${r.tokens_out || 0} out · ${esc(r.model || '')}">${money(r.cost_usd)}${r.tokens_in ? ` · ${Math.round(((r.tokens_cached || 0) / r.tokens_in) * 100)}% cached` : ''}</span>` : '';
const cost = r.cost_usd != null ? `<span class="muted" title="${r.tokens_in || 0} in (${r.tokens_cached || 0} cached) / ${r.tokens_out || 0} out · ${esc(r.model || '')}">${money(r.cost_usd)}${r.tokens_in ? ` · ${Math.round(((r.tokens_cached || 0) / r.tokens_in) * 100)}% cached` : ''}</span>` : '';
- const who = r.bot_username ? `<b>${esc(r.bot_username)}</b>${r.target ? ` <span class="muted">→ ${esc(r.target)}</span>` : ''}` : `<span class="muted">${esc(r.event)}</span>`;
+ const who = r.bot_username ? `<b>${esc(r.bot_username)}</b>${r.target ? ` <span class="muted">to ${esc(r.target)}</span>` : ''}` : `<span class="muted">${esc(r.event)}</span>`;
return `<div class="aiv-log-row aiv-log-${esc(r.event)}"><i class="fa-solid ${icon}"></i><div><div>${who} ${r.text ? `— ${esc(r.text)}` : ''}</div>${r.reason ? `<div class="muted aiv-log-reason">${esc(r.reason)}</div>` : ''}</div><div class="aiv-log-meta">${cost}<span class="muted">${tsAgo(r.created_at)}</span></div></div>`;
return `<div class="aiv-log-row aiv-log-${esc(r.event)}"><i class="fa-solid ${icon}"></i><div><div>${who} ${r.text ? `— ${esc(r.text)}` : ''}</div>${r.reason ? `<div class="muted aiv-log-reason">${esc(r.reason)}</div>` : ''}</div><div class="aiv-log-meta">${cost}<span class="muted">${tsAgo(r.created_at)}</span></div></div>`;
}
}
async function pollLog() {
async function pollLog() {
@@ -272,7 +272,7 @@
@@ -272,7 +272,7 @@
const p = d.plan;
const p = d.plan;
box.innerHTML = `
box.innerHTML = `
<div class="muted" style="font-size:0.78rem">Context: ${d.context ? `${Math.round(d.context.stable_chars / 4)} tokens stable (cached) + ${Math.round(d.context.volatile_chars / 4)} tokens fresh · heard ${d.context.sources.heardAgeMs != null ? ago(d.context.sources.heardAgeMs) : 'nothing'} · seen ${d.context.sources.seenAgeMs != null ? ago(d.context.sources.seenAgeMs) : 'never'} · ${d.context.sources.chatLines} chat lines · ${d.context.sources.people} people known` : ''}${d.cost != null ? ` · this preview cost ${money(d.cost)}${d.usage && d.usage.cached ? ` (${Math.round((d.usage.cached / d.usage.input) * 100)}% cached)` : ''}` : ''}</div>
<div class="muted" style="font-size:0.78rem">Context: ${d.context ? `${Math.round(d.context.stable_chars / 4)} tokens stable (cached) + ${Math.round(d.context.volatile_chars / 4)} tokens fresh · heard ${d.context.sources.heardAgeMs != null ? ago(d.context.sources.heardAgeMs) : 'nothing'} · seen ${d.context.sources.seenAgeMs != null ? ago(d.context.sources.seenAgeMs) : 'never'} · ${d.context.sources.chatLines} chat lines · ${d.context.sources.people} people known` : ''}${d.cost != null ? ` · this preview cost ${money(d.cost)}${d.usage && d.usage.cached ? ` (${Math.round((d.usage.cached / d.usage.input) * 100)}% cached)` : ''}` : ''}</div>
- ${p && p.lines && p.lines.length ? p.lines.map(l => `<div class="aiv-preview-line"><b>${esc(l.bot)}</b> <span class="muted">→ ${esc(l.target)}${l.reply_to ? ' @' + esc(l.reply_to) : ''} · +${Math.round((l.delay_ms || 0) / 1000)}s</span><br>${esc(l.text)}<br><span class="muted aiv-log-reason">${esc(l.reason || '')}</span></div>`).join('') : `<p class="muted">The director would stay quiet right now${p && p.notes ? ` — ${esc(p.notes)}` : ''}.</p>`}
+ ${p && p.lines && p.lines.length ? p.lines.map(l => `<div class="aiv-preview-line"><b>${esc(l.bot)}</b> <span class="muted">to ${esc(l.target)}${l.reply_to ? ' @' + esc(l.reply_to) : ''} · +${Math.round((l.delay_ms || 0) / 1000)}s</span><br>${esc(l.text)}<br><span class="muted aiv-log-reason">${esc(l.reason || '')}</span></div>`).join('') : `<p class="muted">The director would stay quiet right now${p && p.notes ? ` — ${esc(p.notes)}` : ''}.</p>`}
<details style="margin-top:6px"><summary class="muted" style="cursor:pointer;font-size:0.78rem">What the bots were shown</summary><pre class="aiv-pre">${esc(d.stable_preview || '')}\n\n---\n\n${esc(d.volatile_preview || '')}</pre></details>`;
<details style="margin-top:6px"><summary class="muted" style="cursor:pointer;font-size:0.78rem">What the bots were shown</summary><pre class="aiv-pre">${esc(d.stable_preview || '')}\n\n---\n\n${esc(d.volatile_preview || '')}</pre></details>`;
} catch (e) { if (box) box.innerHTML = `<span class="muted">${esc(e.message || 'Preview failed')}</span>`; }
} catch (e) { if (box) box.innerHTML = `<span class="muted">${esc(e.message || 'Preview failed')}</span>`; }
finally { if (btn) btn.disabled = false; }
finally { if (btn) btn.disabled = false; }
@@ -3607,7 +3607,7 @@ function _startCaptions(username, displayName, isLive) {
@@ -3607,7 +3607,7 @@ function _startCaptions(username, displayName, isLive) {
panel.classList.remove('collapsed');
panel.classList.remove('collapsed');
panel.innerHTML = `<div class="ch-captions-head">
panel.innerHTML = `<div class="ch-captions-head">
<span class="ch-captions-title"><span class="ch-captions-dot"></span> <i class="fa-solid fa-closed-captioning"></i> Live translation</span>
<span class="ch-captions-title"><span class="ch-captions-dot"></span> <i class="fa-solid fa-closed-captioning"></i> Live translation</span>
- <span class="ch-captions-sub">${esc(_chLang.flag || '')} ${esc(_chLang.name || '')} → 🇺🇸 English · what ${esc(displayName)} is saying</span>
+ <span class="ch-captions-sub">${esc(_chLang.flag || '')} ${esc(_chLang.name || '')} to 🇺🇸 English · what ${esc(displayName)} is saying</span>
<button class="ch-captions-toggle" type="button" onclick="toggleCaptionsPanel()" title="Collapse / expand"><i class="fa-solid fa-chevron-up"></i></button>
<button class="ch-captions-toggle" type="button" onclick="toggleCaptionsPanel()" title="Collapse / expand"><i class="fa-solid fa-chevron-up"></i></button>
</div>
</div>
<div class="ch-captions-lines" id="ch-captions-lines"><div class="ch-captions-wait">Listening… lines land a few seconds behind live.</div></div>`;
<div class="ch-captions-lines" id="ch-captions-lines"><div class="ch-captions-wait">Listening… lines land a few seconds behind live.</div></div>`;
@@ -86,7 +86,7 @@
@@ -86,7 +86,7 @@
if (/Firefox\//.test(ua)) return 'Firefox shows the prompt just under the address bar. Tick “Remember this decision” so it stops asking.';
if (/Firefox\//.test(ua)) return 'Firefox shows the prompt just under the address bar. Tick “Remember this decision” so it stops asking.';
if (/Edg\//.test(ua)) return 'Edge shows the prompt under the address bar, on the left.';
if (/Edg\//.test(ua)) return 'Edge shows the prompt under the address bar, on the left.';
if (/Chrome\//.test(ua)) return 'Chrome shows the prompt under the address bar, on the left.';
if (/Chrome\//.test(ua)) return 'Chrome shows the prompt under the address bar, on the left.';
- if (/Safari\//.test(ua)) return 'Safari asks once per site — if you miss it, use Safari → Settings for This Website.';
+ if (/Safari\//.test(ua)) return 'Safari asks once per site — if you miss it, use Settings for This Website in Safari.';
return 'Your browser will show the prompt near the address bar.';
return 'Your browser will show the prompt near the address bar.';
}
}
@@ -94,7 +94,7 @@
@@ -94,7 +94,7 @@
const ua = navigator.userAgent;
const ua = navigator.userAgent;
const what = KIND[kind]?.label || 'device';
const what = KIND[kind]?.label || 'device';
if (/Firefox\//.test(ua)) return `Click the padlock in the address bar, then clear the blocked ${what} permission and reload.`;
if (/Firefox\//.test(ua)) return `Click the padlock in the address bar, then clear the blocked ${what} permission and reload.`;
- if (/Safari\//.test(ua)) return `Open Safari → Settings for This Website, set ${what} to Allow, then reload.`;
+ if (/Safari\//.test(ua)) return `Open Settings for This Website in Safari, set ${what} to Allow, then reload.`;
return `Click the padlock (or camera icon) in the address bar, set ${what} to Allow, then reload.`;
return `Click the padlock (or camera icon) in the address bar, set ${what} to Allow, then reload.`;
}
}
@@ -717,7 +717,7 @@ function _wsRenderPanel() {
@@ -717,7 +717,7 @@ function _wsRenderPanel() {
<label class="bc-ws-saver">
<label class="bc-ws-saver">
<input type="checkbox" id="bc-ws-battery-saver"${typeof batterySaverOn === 'function' && batterySaverOn() ? ' checked' : ''}
<input type="checkbox" id="bc-ws-battery-saver"${typeof batterySaverOn === 'function' && batterySaverOn() ? ' checked' : ''}
onchange="setBatterySaver(this.checked); if (broadcastState.activeStreamId) applyBroadcastQuality().catch(() => {})">
onchange="setBatterySaver(this.checked); if (broadcastState.activeStreamId) applyBroadcastQuality().catch(() => {})">
- <span><b>Battery saver on this device</b><small>480p at 30 fps, whatever is set above: a cooler phone and a longer stream. Change it while live under More → Video.</small></span>
+ <span><b>Battery saver on this device</b><small>480p at 30 fps, whatever is set above: a cooler phone and a longer stream. Change it while live under More, then Video.</small></span>
</label>
</label>
</details>
</details>
</div>
</div>
@@ -793,7 +793,7 @@ function _wsRenderPanel() {
@@ -793,7 +793,7 @@ function _wsRenderPanel() {
</label>
</label>
<p class="muted" style="font-size:0.78rem;margin:4px 0 0">
<p class="muted" style="font-size:0.78rem;margin:4px 0 0">
Covers OpenVibe chat, RobotStreamer chat and every restream chat relay on this slot.
Covers OpenVibe chat, RobotStreamer chat and every restream chat relay on this slot.
- Each restream destination below also has its own <strong>→ PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.
+ Each restream destination below also has its own <strong>PowerChat</strong> switch, so you can keep e.g. Twitch chat on the overlay but leave Kick off.
</p>
</p>
</div>
</div>
</div>
</div>
@@ -1785,7 +1785,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
@@ -1785,7 +1785,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
<span class="bc-ws-method-info-note">Use the key shown above</span>
<span class="bc-ws-method-info-note">Use the key shown above</span>
</div>
</div>
<p class="bc-ws-method-info-hint">
<p class="bc-ws-method-info-hint">
- <strong>OBS:</strong> Settings → Stream → Service: Custom → paste server & key above.<br>
+ <strong>OBS:</strong> Settings, then Stream, then Service: Custom, then paste server & key above.<br>
<strong>Streamlabs / IRL Pro:</strong> Use Custom RTMP with the same server & key.
<strong>Streamlabs / IRL Pro:</strong> Use Custom RTMP with the same server & key.
</p>
</p>
</div>`;
</div>`;
@@ -1823,7 +1823,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
@@ -1823,7 +1823,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
<span class="bc-ws-method-info-note">Use the stream key shown above as the Bearer Token in OBS.</span>
<span class="bc-ws-method-info-note">Use the stream key shown above as the Bearer Token in OBS.</span>
</div>
</div>
<p class="bc-ws-method-info-hint">
<p class="bc-ws-method-info-hint">
- <strong>OBS WHIP setup:</strong> Settings → Stream → Service: WHIP → paste URL above.
+ <strong>OBS WHIP setup:</strong> Settings, then Stream, then Service: WHIP, then paste URL above.
Set Bearer Token to your stream key. Your stream will auto-start when OBS begins transmitting.
Set Bearer Token to your stream key. Your stream will auto-start when OBS begins transmitting.
</p>
</p>
</div>`;
</div>`;
@@ -1871,7 +1871,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
@@ -1871,7 +1871,7 @@ function _wsRenderMethodEndpoint(method, streamKey, managedStreamId) {
${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre>
${esc(jsmpegServer)}/${esc(streamKey)}/640/480/</pre>
<h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>
<h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>
- <pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera → JSMPEG
+ <pre class="bc-ws-cli-code"># Pi Camera v2 / libcamera to JSMPEG
rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
--codec yuv420 -o - | \\
--codec yuv420 -o - | \\
ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -r 24 -i - \\
ffmpeg -f rawvideo -pix_fmt yuv420p -s 640x480 -r 24 -i - \\
@@ -1936,7 +1936,7 @@ rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
@@ -1936,7 +1936,7 @@ rpicam-vid -t 0 --width 640 --height 480 --framerate 24 \\
-f flv ${esc(rtmpServer)}/${esc(streamKey)}</pre>
-f flv ${esc(rtmpServer)}/${esc(streamKey)}</pre>
<h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>
<h5><i class="fa-solid fa-microchip"></i> Raspberry Pi Camera</h5>
- <pre class="bc-ws-cli-code"># Pi Camera → RTMP
+ <pre class="bc-ws-cli-code"># Pi Camera to RTMP
rpicam-vid -t 0 --width 1280 --height 720 --framerate 30 \\
rpicam-vid -t 0 --width 1280 --height 720 --framerate 30 \\
--codec h264 --bitrate 2000000 -o - | \\
--codec h264 --bitrate 2000000 -o - | \\
ffmpeg -f h264 -i - -c:v copy -an \\
ffmpeg -f h264 -i - -c:v copy -an \\
@@ -54,7 +54,7 @@ function parseRsRestreamSlotValue() {
@@ -54,7 +54,7 @@ function parseRsRestreamSlotValue() {
// Migrate to managed format in-place
// Migrate to managed format in-place
const migratedId = ss.streamData.managed_stream_id;
const migratedId = ss.streamData.managed_stream_id;
localStorage.setItem('bc-rs-restream-slot', `m:${migratedId}`);
localStorage.setItem('bc-rs-restream-slot', `m:${migratedId}`);
- console.log(`[RS Slot] Migrated legacy s:${legacyId} → m:${migratedId}`);
+ console.log(`[RS Slot] Migrated legacy s:${legacyId} to m:${migratedId}`);
return { managedStreamId: migratedId };
return { managedStreamId: migratedId };
}
}
}
}
@@ -222,7 +222,7 @@
@@ -222,7 +222,7 @@
function renderSoundTab(body) {
function renderSoundTab(body) {
body.innerHTML = `
body.innerHTML = `
<form class="cu-form" id="cu-sound-form">
<form class="cu-form" id="cu-sound-form">
- <input type="text" id="cu-sound-cmd" maxlength="24" placeholder="Command name (e.g. airhorn) → !airhorn" autocomplete="off">
+ <input type="text" id="cu-sound-cmd" maxlength="24" placeholder="Command name (e.g. airhorn, then !airhorn)" autocomplete="off">
<input type="file" id="cu-sound-file" accept="audio/*" multiple>
<input type="file" id="cu-sound-file" accept="audio/*" multiple>
<input type="text" id="cu-sound-emote" maxlength="32" placeholder="Attach an emote code (optional) — shows the emote instead of “played !cmd”" autocomplete="off">
<input type="text" id="cu-sound-emote" maxlength="32" placeholder="Attach an emote code (optional) — shows the emote instead of “played !cmd”" autocomplete="off">
<div id="cu-sound-preview" style="display:none;margin:2px 0"></div>
<div id="cu-sound-preview" style="display:none;margin:2px 0"></div>
@@ -303,7 +303,7 @@
@@ -303,7 +303,7 @@
</div>
</div>
<div class="cu-set-group">
<div class="cu-set-group">
<div class="cu-set-group-title">Channel Points</div>
<div class="cu-set-group-title">Channel Points</div>
- <div class="cu-hint">Channel Points, rewards, and the redemption queue moved to your <b>Dashboard → Points</b> tab.</div>
+ <div class="cu-hint">Channel Points, rewards, and the redemption queue moved to the <b>Points</b> tab in your <b>Dashboard</b>.</div>
</div>
</div>
<button class="cu-btn" id="cu-set-save" type="button">Save channel settings</button>
<button class="cu-btn" id="cu-set-save" type="button">Save channel settings</button>
<div class="cu-hint">Applies to everyone in your channel's chat. Pitch/speed limits live in your dashboard's moderation panel.</div>
<div class="cu-hint">Applies to everyone in your channel's chat. Pitch/speed limits live in your dashboard's moderation panel.</div>
@@ -1871,7 +1871,7 @@ function _updateTranslateHint(lang) {
@@ -1871,7 +1871,7 @@ function _updateTranslateHint(lang) {
if (!pill) { pill = document.createElement('span'); pill.className = 'chat-translate-hint'; row.appendChild(pill); }
if (!pill) { pill = document.createElement('span'); pill.className = 'chat-translate-hint'; row.appendChild(pill); }
const name = _LANG_LABEL[_chatChannelLang] || _chatChannelLang;
const name = _LANG_LABEL[_chatChannelLang] || _chatChannelLang;
pill.title = `This streamer reads ${name}. Type in YOUR language — every message is auto-translated to ${name} for them, and their ${name} is translated to English for you.`;
pill.title = `This streamer reads ${name}. Type in YOUR language — every message is auto-translated to ${name} for them, and their ${name} is translated to English for you.`;
- pill.innerHTML = `<i class="fa-solid fa-language"></i> <span>Auto-translated ↔ ${esc(name)}</span>`;
+ pill.innerHTML = `<i class="fa-solid fa-language"></i> <span>Auto-translated with ${esc(name)}</span>`;
});
});
}
}
@@ -2824,13 +2824,13 @@ function addRichSystemMessage(html, style = 'info') {
@@ -2824,13 +2824,13 @@ function addRichSystemMessage(html, style = 'info') {
/* ── Event cards: someone went live, a Rundown episode is out ──────────────────
/* ── Event cards: someone went live, a Rundown episode is out ──────────────────
* Live posts these as system rows ("🔴 <name> is live. Share the stream: <url>", metadata.kind 'live_link';
* Live posts these as system rows ("🔴 <name> is live. Share the stream: <url>", metadata.kind 'live_link';
- * "📰 <name>: “<title>” → <url>", metadata.rundown_episode). They were italic blue sentences with a bare URL; now
+ * "📰 <name>: “<title>” at <url>", metadata.rundown_episode). They were italic blue sentences with a bare URL; now
* each is a small card like the deploy card: what happened, who, when (with the viewer's timestamp setting, always in
* each is a small card like the deploy card: what happened, who, when (with the viewer's timestamp setting, always in
* global chat as messages are), a Watch/Read button and a copy-link button. Old rows are recognised by their text.
* global chat as messages are), a Watch/Read button and a copy-link button. Old rows are recognised by their text.
* The same person going live again within 15 minutes updates the card instead of adding another. Built from DOM
* The same person going live again within 15 minutes updates the card instead of adding another. Built from DOM
* nodes; a link is used only when it is an OpenVibe address. */
* nodes; a link is used only when it is an OpenVibe address. */
const _CHAT_EV_LIVE_RE = /^\s*\u{1F534}\s*(.+?) is live\. Share the stream:\s*(https?:\/\/\S+)\s*$/u;
const _CHAT_EV_LIVE_RE = /^\s*\u{1F534}\s*(.+?) is live\. Share the stream:\s*(https?:\/\/\S+)\s*$/u;
-const _CHAT_EV_RUNDOWN_RE = /^\s*\u{1F4F0}\s*(.+?):\s*[“"](.+)[”"]\s*→\s*(https?:\/\/\S+)\s*$/u;
+const _CHAT_EV_RUNDOWN_RE = /^\s*\u{1F4F0}\s*(.+?):\s*[“"](.+)[”"]\s+(?:\u2192|at)\s+(https?:\/\/\S+)\s*$/u;
const _CHAT_EV_FOLD_MS = 15 * 60 * 1000;
const _CHAT_EV_FOLD_MS = 15 * 60 * 1000;
function _chatEvSafeUrl(u) {
function _chatEvSafeUrl(u) {
try {
try {
@@ -4272,7 +4272,7 @@ async function ctxRenameUsername(username, userId) {
@@ -4272,7 +4272,7 @@ async function ctxRenameUsername(username, userId) {
if (newUsername == null || !newUsername.trim()) return;
if (newUsername == null || !newUsername.trim()) return;
try {
try {
await api(`/admin/users/${userId}`, { method: 'PUT', body: { username: newUsername.trim() } });
await api(`/admin/users/${userId}`, { method: 'PUT', body: { username: newUsername.trim() } });
- toast(`Username changed: @${username} → @${newUsername.trim()}`, 'success');
+ toast(`Username changed: @${username} to @${newUsername.trim()}`, 'success');
} catch (err) {
} catch (err) {
toast('Failed to rename username: ' + (err.message || 'unknown error'), 'error');
toast('Failed to rename username: ' + (err.message || 'unknown error'), 'error');
}
}
@@ -4284,7 +4284,7 @@ async function ctxRenameDisplayName(username, userId, currentDisplay) {
@@ -4284,7 +4284,7 @@ async function ctxRenameDisplayName(username, userId, currentDisplay) {
if (newName == null || !newName.trim()) return;
if (newName == null || !newName.trim()) return;
try {
try {
await api(`/admin/users/${userId}`, { method: 'PUT', body: { display_name: newName.trim() } });
await api(`/admin/users/${userId}`, { method: 'PUT', body: { display_name: newName.trim() } });
- toast(`Display name changed: @${username} → ${newName.trim()}`, 'success');
+ toast(`Display name changed: @${username} to ${newName.trim()}`, 'success');
} catch (err) {
} catch (err) {
toast('Failed to rename display name: ' + (err.message || 'unknown error'), 'error');
toast('Failed to rename display name: ' + (err.message || 'unknown error'), 'error');
}
}
@@ -477,7 +477,7 @@ function showEndpointInfo(stream) {
@@ -477,7 +477,7 @@ function showEndpointInfo(stream) {
} else if (proto === 'webrtc') {
} else if (proto === 'webrtc') {
info = `Protocol: WebRTC\n\nUse the browser "Start Broadcasting" feature.\nYour webcam/screen will be shared via Mediasoup SFU.`;
info = `Protocol: WebRTC\n\nUse the browser "Start Broadcasting" feature.\nYour webcam/screen will be shared via Mediasoup SFU.`;
} else if (proto === 'rtmp') {
} else if (proto === 'rtmp') {
- info = `Protocol: RTMP\n\nServer: rtmp://${host}:1935/live\nStream Key: ${stream.stream_key || '(use your stream key)'}\n\nIn OBS: Settings → Stream → Custom → paste above.`;
+ info = `Protocol: RTMP\n\nServer: rtmp://${host}:1935/live\nStream Key: ${stream.stream_key || '(use your stream key)'}\n\nIn OBS: Settings, then Stream, then Custom, then paste above.`;
}
}
el.textContent = info;
el.textContent = info;
@@ -991,7 +991,7 @@ function _bucksTxRow(t) {
@@ -991,7 +991,7 @@ function _bucksTxRow(t) {
else if (t.type === 'donation') { icon = 'fa-gift'; if (!t.label) label = `Received from ${esc(t.from_display || t.from_username || 'someone')}`; sign = '+'; cls = 'pos'; }
else if (t.type === 'donation') { icon = 'fa-gift'; if (!t.label) label = `Received from ${esc(t.from_display || t.from_username || 'someone')}`; sign = '+'; cls = 'pos'; }
else if (t.type === 'cashout' || t.type === 'cashout_request') { icon = 'fa-money-bill-transfer'; if (!t.label) label = 'Cashout requested'; sign = '−'; cls = 'neg'; }
else if (t.type === 'cashout' || t.type === 'cashout_request') { icon = 'fa-money-bill-transfer'; if (!t.label) label = 'Cashout requested'; sign = '−'; cls = 'neg'; }
else if (t.type === 'refund') { icon = 'fa-rotate-left'; if (!t.label) label = 'Cashout refunded'; sign = '+'; cls = 'pos'; }
else if (t.type === 'refund') { icon = 'fa-rotate-left'; if (!t.label) label = 'Cashout refunded'; sign = '+'; cls = 'pos'; }
- else if (t.type === 'recycle') { icon = 'fa-recycle'; if (!t.label) label = 'Moved cashout → spendable'; sign = ''; cls = ''; }
+ else if (t.type === 'recycle') { icon = 'fa-recycle'; if (!t.label) label = 'Moved cashout to spendable'; sign = ''; cls = ''; }
const url = t.subject && t.subject.url;
const url = t.subject && t.subject.url;
const text = url && /^https:\/\//.test(url)
const text = url && /^https:\/\//.test(url)
? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>` : esc(label);
? `<a href="${esc(url)}" target="_blank" rel="noopener">${esc(label)}</a>` : esc(label);
@@ -172,8 +172,8 @@
@@ -172,8 +172,8 @@
const d = el.querySelector('#ovg-method-detail'); if (!d) return;
const d = el.querySelector('#ovg-method-detail'); if (!d) return;
const key = D.key || '(loading…)';
const key = D.key || '(loading…)';
if (D.method === 'browser') d.innerHTML = ui.box('Browser streaming', `<p>When you press <b>Go live</b> we'll ask for your camera and mic (or a screen to share). Nothing to install.</p><div class="ovg-seg"><button type="button" class="${D.mode === 'camera' ? 'on' : ''}" data-mode="camera"><i class="fa-solid fa-video"></i> Camera + mic</button><button type="button" class="${D.mode === 'screen' ? 'on' : ''}" data-mode="screen"><i class="fa-solid fa-display"></i> Share my screen</button></div><ul class="ovg-tips"><li>Phones work great — landscape looks best.</li><li>Good light beats a good camera.</li></ul>`, 'fa-globe');
if (D.method === 'browser') d.innerHTML = ui.box('Browser streaming', `<p>When you press <b>Go live</b> we'll ask for your camera and mic (or a screen to share). Nothing to install.</p><div class="ovg-seg"><button type="button" class="${D.mode === 'camera' ? 'on' : ''}" data-mode="camera"><i class="fa-solid fa-video"></i> Camera + mic</button><button type="button" class="${D.mode === 'screen' ? 'on' : ''}" data-mode="screen"><i class="fa-solid fa-display"></i> Share my screen</button></div><ul class="ovg-tips"><li>Phones work great — landscape looks best.</li><li>Good light beats a good camera.</li></ul>`, 'fa-globe');
- else if (D.method === 'rtmp') d.innerHTML = ui.box('Paste these into OBS', `${ui.kv('Server', D.rtmpUrl || 'rtmp://openvibe.live/live', true)}${ui.secret('Stream key', key)}${ui.steps(['<b>Settings</b> → <b>Stream</b>', 'Service: <b>Custom…</b>', 'Paste the server and the key', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">Keep the key private — anyone with it can stream as you. You can regenerate it on the Go Live page.</p>`, 'fa-desktop');
- else d.innerHTML = ui.box('OBS 30+ over WHIP', `${ui.kv('Server', `${D.whipBase || location.origin}/whip/${D.slot.id}`, true)}${ui.secret('Bearer token', key)}${ui.steps(['<b>Settings</b> → <b>Stream</b>', 'Service: <b>WHIP</b>', 'Paste the server and the bearer token', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">WHIP is WebRTC: viewers see you with under a second of delay.</p>`, 'fa-bolt');
+ else if (D.method === 'rtmp') d.innerHTML = ui.box('Paste these into OBS', `${ui.kv('Server', D.rtmpUrl || 'rtmp://openvibe.live/live', true)}${ui.secret('Stream key', key)}${ui.steps(['<b>Settings</b>, then <b>Stream</b>', 'Service: <b>Custom…</b>', 'Paste the server and the key', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">Keep the key private — anyone with it can stream as you. You can regenerate it on the Go Live page.</p>`, 'fa-desktop');
+ else d.innerHTML = ui.box('OBS 30+ over WHIP', `${ui.kv('Server', `${D.whipBase || location.origin}/whip/${D.slot.id}`, true)}${ui.secret('Bearer token', key)}${ui.steps(['<b>Settings</b>, then <b>Stream</b>', 'Service: <b>WHIP</b>', 'Paste the server and the bearer token', '<b>OK</b>, then <b>Start Streaming</b>'])}<p class="muted ovg-fine">WHIP is WebRTC: viewers see you with under a second of delay.</p>`, 'fa-bolt');
ui.wire(d);
ui.wire(d);
d.querySelectorAll('[data-mode]').forEach(b => b.addEventListener('click', async () => { D.mode = b.dataset.mode; d.querySelectorAll('[data-mode]').forEach(x => x.classList.remove('on')); b.classList.add('on'); try { await api(`/streams/managed/${D.slot.id}`, { method: 'PUT', body: { browser_mode: D.mode } }); D.slot.browser_mode = D.mode; } catch { /* */ } }));
d.querySelectorAll('[data-mode]').forEach(b => b.addEventListener('click', async () => { D.mode = b.dataset.mode; d.querySelectorAll('[data-mode]').forEach(x => x.classList.remove('on')); b.classList.add('on'); try { await api(`/streams/managed/${D.slot.id}`, { method: 'PUT', body: { browser_mode: D.mode } }); D.slot.browser_mode = D.mode; } catch { /* */ } }));
};
};
@@ -227,9 +227,9 @@
@@ -227,9 +227,9 @@
const [name, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';
const [name, icon] = PLAT[plat] || PLAT.custom; const needsUrl = plat === 'custom' || plat === 'kick';
const oauth = (D.oauth && D.oauth[plat]) || null;
const oauth = (D.oauth && D.oauth[plat]) || null;
const canConnect = !!(oauth && oauth.configured);
const canConnect = !!(oauth && oauth.configured);
- const manualHelp = plat === 'twitch' ? 'Twitch → Creator Dashboard → Settings → Stream → copy the <b>Primary Stream key</b>.'
- : plat === 'youtube' ? 'YouTube Studio → Go live → copy the <b>Stream key</b>.'
- : plat === 'kick' ? 'Kick → Creator Dashboard → Settings → Stream key: copy the <b>URL</b> and the <b>key</b>.'
+ const manualHelp = plat === 'twitch' ? 'Twitch: open Creator Dashboard, then Settings, then Stream, then copy the <b>Primary Stream key</b>.'
+ : plat === 'youtube' ? 'YouTube Studio: go live, then copy the <b>Stream key</b>.'
+ : plat === 'kick' ? 'Kick: Creator Dashboard, then Settings, then Stream key: copy the <b>URL</b> and the <b>key</b>.'
: 'Paste the RTMP server URL and stream key from the service you want to mirror to.';
: 'Paste the RTMP server URL and stream key from the service you want to mirror to.';
// Two ways in, and the reader picks. Signing in is one tap and fills the key in for them;
// Two ways in, and the reader picks. Signing in is one tap and fills the key in for them;
@@ -313,7 +313,7 @@
@@ -313,7 +313,7 @@
one('offline', 'Offline screen', 'fa-image', async () => `${ui.lead('What visitors see on your channel when you are not live: an image, a looping video, or your own HTML page. It sits above your top content and the discover board.')}${ui.grid([ui.pick({ id: 'image', icon: 'fa-solid fa-image', title: 'Image', sub: 'a banner or a still from your stream' }), ui.pick({ id: 'video', icon: 'fa-solid fa-film', title: 'Video loop', sub: 'a short muted clip' }), ui.pick({ id: 'html', icon: 'fa-solid fa-code', title: 'Custom HTML', sub: 'anything you can build' })], 3)}<p class="muted ovg-fine">The upload lives in your dashboard — the button below takes you straight to it and points at the right spot.</p>`,
one('offline', 'Offline screen', 'fa-image', async () => `${ui.lead('What visitors see on your channel when you are not live: an image, a looping video, or your own HTML page. It sits above your top content and the discover board.')}${ui.grid([ui.pick({ id: 'image', icon: 'fa-solid fa-image', title: 'Image', sub: 'a banner or a still from your stream' }), ui.pick({ id: 'video', icon: 'fa-solid fa-film', title: 'Video loop', sub: 'a short muted clip' }), ui.pick({ id: 'html', icon: 'fa-solid fa-code', title: 'Custom HTML', sub: 'anything you can build' })], 3)}<p class="muted ovg-fine">The upload lives in your dashboard — the button below takes you straight to it and points at the right spot.</p>`,
(el) => { el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); })); },
(el) => { el.querySelectorAll('[data-pick]').forEach(b => b.addEventListener('click', () => { el.querySelectorAll('[data-pick]').forEach(x => x.classList.remove('on')); b.classList.add('on'); })); },
async () => { dashboard('#dash-card-offline', 'Offline Screen', 'Pick an image, a looping video or your own HTML — this is what visitors see when you are not live.'); return true; }, 'Open the offline screen settings');
async () => { dashboard('#dash-card-offline', 'Offline Screen', 'Pick an image, a looping video or your own HTML — this is what visitors see when you are not live.'); return true; }, 'Open the offline screen settings');
- one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b> → <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,
+ one('panels', 'Your About panels', 'fa-table-columns', async () => `${ui.lead('Panels are the blocks under your player: schedule, links, rules, gear, whatever you want people to know. They live on your channel page and in the About tab.')}${ui.steps(['Open your channel page', 'Tap <b>About</b>, then <b>Edit panels</b>', 'Add a title and text (links and images work)', 'Save — it shows up for everyone right away'])}`,
null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');
null, async () => { const u = me(); G.close(false); G.cfg.navigate(`/@${(u && u.username) || ''}#about`); return true; }, 'Go to my channel');
one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,
one('powerchat', 'Connect PowerChat', 'fa-hand-holding-dollar', async () => `${ui.lead('PowerChat handles real-money tips (card and crypto) for OpenVibe streamers, with alerts on your stream and a tip link you can share anywhere.')}${ui.steps(['Press <b>Connect PowerChat</b> — you sign in there once', 'Tips land as chat alerts with the amount and message', 'Your channel gets a <b>Tip</b> button and a shareable tip link'])}<p class="muted ovg-fine">Nothing to pay to set up; PowerChat takes its cut per tip.</p>`,
null, async () => { G.close(false); location.href = '/api/powerchat/oauth/start'; return true; }, 'Connect PowerChat');
null, async () => { G.close(false); location.href = '/api/powerchat/oauth/start'; return true; }, 'Connect PowerChat');
@@ -95,7 +95,7 @@
@@ -95,7 +95,7 @@
: (notes.length ? notes.map(d => d.message).join(' ')
: (notes.length ? notes.map(d => d.message).join(' ')
: (okDiag ? okDiag.message : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.'));
: (okDiag ? okDiag.message : 'Tips confirmed on PowerChat now flow into your goals, alerts, and chat automatically.'));
// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.
// Relay routing lives on the Broadcast page — streamers rarely find it unprompted.
- hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a> → a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>→ PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;
+ hintEl.innerHTML = `${esc(base)}<br><span class="pc-tip"><i class="fa-solid fa-lightbulb"></i> Tip: chat from every stream slot and restream (Twitch, Kick, YouTube, RobotStreamer) is merged into your PowerChat overlay by default. To turn that off for a specific slot or platform, open <a href="/broadcast" onclick="event.preventDefault();navigate('/broadcast')">Broadcast</a>, then a slot's <strong>PowerChat Overlay</strong> settings, or the <strong>PowerChat</strong> switches in each restream destination. The viewer count on your overlay is your total audience (OpenVibe + restream platforms + RobotStreamer) — the same switches let you leave a platform's viewers out.</span>`;
}
}
} else {
} else {
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
_swap(statusEl, '<span class="pc-dot pc-dot-off"></span> Not connected.');
@@ -654,7 +654,7 @@ button{font:inherit}
@@ -654,7 +654,7 @@ button{font:inherit}
<script>
<script>
(() => {
(() => {
"use strict";
"use strict";
- // Served from openvibe.live/kiosk → everything is same-origin (no CORS, no cross-host WS).
+ // Served from openvibe.live/kiosk, so everything is same-origin (no CORS, no cross-host WS).
const API="/api";
const API="/api";
const WS_URL=(location.protocol==="https:"?"wss://":"ws://")+location.host+"/ws/chat";
const WS_URL=(location.protocol==="https:"?"wss://":"ws://")+location.host+"/ws/chat";
const $=s=>document.querySelector(s);
const $=s=>document.querySelector(s);
@@ -1046,7 +1046,7 @@ button{font:inherit}
@@ -1046,7 +1046,7 @@ button{font:inherit}
const LS_PROV="openvibe_kiosk_search", LS_HIST="openvibe_kiosk_history";
const LS_PROV="openvibe_kiosk_search", LS_HIST="openvibe_kiosk_history";
let provider=localStorage.getItem(LS_PROV); if(!PROVIDERS[provider]) provider="google";
let provider=localStorage.getItem(LS_PROV); if(!PROVIDERS[provider]) provider="google";
let hist=[]; try{ hist=JSON.parse(localStorage.getItem(LS_HIST)||"[]"); if(!Array.isArray(hist)) hist=[]; }catch{}
let hist=[]; try{ hist=JSON.parse(localStorage.getItem(LS_HIST)||"[]"); if(!Array.isArray(hist)) hist=[]; }catch{}
- const resolved=new Map(); // domain(lowercased) → {reachable,url,host,title}
+ const resolved=new Map(); // domain (lowercased): {reachable,url,host,title}
const resolving=new Set();
const resolving=new Set();
let curItems=[], selIdx=-1, isOpen=false, resolveTimer=null;
let curItems=[], selIdx=-1, isOpen=false, resolveTimer=null;
@@ -1130,7 +1130,7 @@ button{font:inherit}
@@ -1130,7 +1130,7 @@ button{font:inherit}
if(looksLikeDomain(q)){
if(looksLikeDomain(q)){
const key=q.toLowerCase(), c=resolved.get(key);
const key=q.toLowerCase(), c=resolved.get(key);
if(c && !c.reachable){
if(c && !c.reachable){
- // resolved as unreachable → offer search only
+ // resolved as unreachable, so offer search only
} else if(c && c.reachable){
} else if(c && c.reachable){
add({type:"domain",title:c.title||q,sub:c.host+(c.title?" · "+c.title:""),host:c.host,url:c.url});
add({type:"domain",title:c.title||q,sub:c.host+(c.title?" · "+c.title:""),host:c.host,url:c.url});
} else {
} else {
@@ -1244,7 +1244,7 @@ button{font:inherit}
@@ -1244,7 +1244,7 @@ button{font:inherit}
document.addEventListener("click",e=>{ if(!provMenu.hidden && !provMenu.contains(e.target) && e.target!==provBtn && !provBtn.contains(e.target)) setProvMenu(false); });
document.addEventListener("click",e=>{ if(!provMenu.hidden && !provMenu.contains(e.target) && e.target!==provBtn && !provBtn.contains(e.target)) setProvMenu(false); });
document.addEventListener("keydown",e=>{ if(e.key==="Escape" && !provMenu.hidden){ e.stopPropagation(); setProvMenu(false); } },true);
document.addEventListener("keydown",e=>{ if(e.key==="Escape" && !provMenu.hidden){ e.stopPropagation(); setProvMenu(false); } },true);
- // Global: start typing anywhere → focus the (possibly ghosted or hidden) bar and seed it.
+ // Global: start typing anywhere, focus the (possibly ghosted or hidden) bar and seed it.
document.addEventListener("keydown",e=>{
document.addEventListener("keydown",e=>{
if(document.activeElement===input) return; // already typing in the bar — let it be
if(document.activeElement===input) return; // already typing in the bar — let it be
if(e.key==="Escape") return;
if(e.key==="Escape") return;
@@ -502,7 +502,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
@@ -502,7 +502,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
let reconnectDelay = RECONNECT_BASE;
let reconnectDelay = RECONNECT_BASE;
let reconnectTimer = null;
let reconnectTimer = null;
let intentionalClose = false;
let intentionalClose = false;
- let emoteMap = {}; // code → {url, animated}
+ let emoteMap = {}; // code: {url, animated}
let fadeTimers = [];
let fadeTimers = [];
// ── URL params for customization ───────────────────────────
// ── URL params for customization ───────────────────────────
@@ -531,7 +531,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
@@ -531,7 +531,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
document.body.classList.add('no-shadow');
document.body.classList.add('no-shadow');
}
}
}
}
- // ?streamtitle=1 → show which stream slot each message came from (default off).
+ // ?streamtitle=1 shows which stream slot each message came from (default off).
// Handy for a per-user overlay aggregating several live slots.
// Handy for a per-user overlay aggregating several live slots.
const showStreamTitle = ['1', 'true', 'on', 'yes'].includes(String(params.get('streamtitle') || '').toLowerCase());
const showStreamTitle = ['1', 'true', 'on', 'yes'].includes(String(params.get('streamtitle') || '').toLowerCase());
@@ -629,7 +629,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
@@ -629,7 +629,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
YouTube: '#ff0000',
YouTube: '#ff0000',
RS: '#7dd3fc',
RS: '#7dd3fc',
};
};
- // Relay platform → icon badge (replaces the old "[Twitch]/[RS]" text prefix).
+ // Relay platform: icon badge (replaces the old "[Twitch]/[RS]" text prefix).
const RELAY_BADGE = {
const RELAY_BADGE = {
twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch' },
twitch: { icon: 'fa-brands fa-twitch', label: 'Twitch' },
kick: { icon: 'fa-solid fa-bolt', label: 'Kick' },
kick: { icon: 'fa-solid fa-bolt', label: 'Kick' },
@@ -705,7 +705,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
@@ -705,7 +705,7 @@ html[data-ov-motion=off] .name-fx-twofat{filter: drop-shadow(0 0 3px rgba(255, 4
// Suppress noisy OpenVibe.Live join/system lines (a clean "connected" shows on open).
// Suppress noisy OpenVibe.Live join/system lines (a clean "connected" shows on open).
if (msg.type === 'system' && OV_JOIN_RE.test(_rawText)) return;
if (msg.type === 'system' && OV_JOIN_RE.test(_rawText)) return;
- // RobotStreamer relayed noise → suppress; announce RS connection once on first RS msg.
+ // RobotStreamer relayed noise: suppress; announce RS connection once on first RS msg.
if (_isRs) {
if (_isRs) {
if (!_rsConnectedShown) { _rsConnectedShown = true; _showConnected('Connected to RobotStreamer chat successfully'); }
if (!_rsConnectedShown) { _rsConnectedShown = true; _showConnected('Connected to RobotStreamer chat successfully'); }
if (RS_NOISE_RE.test(_rawText)) return;
if (RS_NOISE_RE.test(_rawText)) return;
@@ -275,7 +275,7 @@
@@ -275,7 +275,7 @@
(function parseUrl() {
(function parseUrl() {
const seg = window.location.pathname.split('/').filter(Boolean); // ['popout'|'popout-chat', user?, id?]
const seg = window.location.pathname.split('/').filter(Boolean); // ['popout'|'popout-chat', user?, id?]
if (seg[0] === 'popout' || seg[0] === 'popout-chat') {
if (seg[0] === 'popout' || seg[0] === 'popout-chat') {
- // No username (or explicit "global") → global chat.
+ // No username (or explicit "global") means global chat.
if (!seg[1] || seg[1].toLowerCase() === 'global') { mode = 'global'; return; }
if (!seg[1] || seg[1].toLowerCase() === 'global') { mode = 'global'; return; }
mode = 'channel';
mode = 'channel';
channelName = decodeURIComponent(seg[1]);
channelName = decodeURIComponent(seg[1]);
@@ -314,7 +314,7 @@
@@ -314,7 +314,7 @@
// across tabs by the shared storage listener.
// across tabs by the shared storage listener.
// Request the viewer list over the shared chat WebSocket. The reply is
// Request the viewer list over the shared chat WebSocket. The reply is
- // handled by chat.js → renderChatUsersList(), which now also fills #pc-users-body.
+ // handled by chat.js: renderChatUsersList(), which now also fills #pc-users-body.
window.requestUserList = function() {
window.requestUserList = function() {
try {
try {
if (typeof chatWs !== 'undefined' && chatWs && chatWs.readyState === WebSocket.OPEN) {
if (typeof chatWs !== 'undefined' && chatWs && chatWs.readyState === WebSocket.OPEN) {
@@ -407,9 +407,9 @@
@@ -407,9 +407,9 @@
}
}
// Decide which stream the popout SHOULD be in right now:
// Decide which stream the popout SHOULD be in right now:
- // pinned stream while it's live → the stream we're already in while it's live
- // (concurrent slots can reorder by viewer count — never flap between them) →
- // any other live stream → channel chat (null).
+ // pinned stream while it's live: the stream we're already in while it's live
+ // (concurrent slots can reorder by viewer count — never flap between them), then
+ // any other live stream: channel chat (null).
function desiredTarget(liveStreams) {
function desiredTarget(liveStreams) {
const liveIds = liveStreams.map(s => Number(s.id));
const liveIds = liveStreams.map(s => Number(s.id));
if (pinnedStreamId && liveIds.includes(Number(pinnedStreamId))) return Number(pinnedStreamId);
if (pinnedStreamId && liveIds.includes(Number(pinnedStreamId))) return Number(pinnedStreamId);
@@ -69,7 +69,7 @@
@@ -69,7 +69,7 @@
<div class="card">
<div class="card">
<label for="whip-url">WHIP URL</label>
<label for="whip-url">WHIP URL</label>
<input id="whip-url" type="url" placeholder="https://ingest.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false">
<input id="whip-url" type="url" placeholder="https://ingest.openvibe.live/whip/<your-stream-key>" autocomplete="off" spellcheck="false">
- <p class="hint">Copy it from <strong>Broadcast → your stream → Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
+ <p class="hint">Copy it from <strong>Broadcast, then your stream, then Streaming method: WHIP</strong>. The key inside the URL is your credential — treat the URL like a password. It is remembered in this browser only.</p>
<label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>
<label for="whip-token">Bearer token <span style="text-transform:none;letter-spacing:0">(optional — only needed if your URL uses the numeric slot form <code>/whip/42</code>)</span></label>
<input id="whip-token" type="password" placeholder="stream key" autocomplete="off">
<input id="whip-token" type="password" placeholder="stream key" autocomplete="off">
@@ -216,7 +216,7 @@
@@ -216,7 +216,7 @@
els.preview.srcObject = stream;
els.preview.srcObject = stream;
for (const track of stream.getTracks()) {
for (const track of stream.getTracks()) {
log(`${track.kind} track: ${track.label || 'unnamed'}`);
log(`${track.kind} track: ${track.label || 'unnamed'}`);
- // "Stop sharing" in the browser's screen-share bar ends the track → end the session.
+ // "Stop sharing" in the browser's screen-share bar ends the track, so end the session.
track.addEventListener('ended', () => { if (state.phase === 'live' || state.phase === 'connecting') stop('source ended'); });
track.addEventListener('ended', () => { if (state.phase === 'live' || state.phase === 'connecting') stop('source ended'); });
}
}
@@ -77,7 +77,7 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
@@ -77,7 +77,7 @@ async function _fetchJson(method, path, body, timeoutMs, retried = false) {
if (!res.ok && res.status !== 202) {
if (!res.ok && res.status !== 202) {
// AI is resting (ai.resting): keep its sentence so a product can show it instead of "could not answer".
// AI is resting (ai.resting): keep its sentence so a product can show it instead of "could not answer".
if (res.status === 503 && json && json.code === 'ai.resting') _lastProblem = { ...RESTING, retry_after_seconds: Number(res.headers.get('retry-after')) || null };
if (res.status === 503 && json && json.code === 'ai.resting') _lastProblem = { ...RESTING, retry_after_seconds: Number(res.headers.get('retry-after')) || null };
- warn(`${method} ${path} -> ${res.status} ${(json && (json.code || json.detail)) || ''}`);
+ warn(`${method} ${path} returned ${res.status} ${(json && (json.code || json.detail)) || ''}`);
return null;
return null;
}
}
return json;
return json;
@@ -183,7 +183,7 @@ async function _checkLiveStream(stream) {
@@ -183,7 +183,7 @@ async function _checkLiveStream(stream) {
// deduplicated: Media handed back a clip someone already cut of this window. It stays in
// deduplicated: Media handed back a clip someone already cut of this window. It stays in
// the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).
// the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).
await _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: clipStart, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: verdict.desc || '', speech, sig: _sceneSig(verdict.desc || title), score: rated.score, visibility: rated.verdict === 'public' ? 'public' : 'unlisted', bucket: rated.bucket, peak: peak.count, chatters: chatters == null ? undefined : chatters });
await _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: clipStart, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: verdict.desc || '', speech, sig: _sceneSig(verdict.desc || title), score: rated.score, visibility: rated.verdict === 'public' ? 'public' : 'unlisted', bucket: rated.bucket, peak: peak.count, chatters: chatters == null ? undefined : chatters });
- console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs, score ${rated.score} → ${rated.verdict}${cut.snapped ? ', on sentences' : ''}`);
+ console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs, score ${rated.score}, verdict ${rated.verdict}${cut.snapped ? ', on sentences' : ''}`);
}
}
} catch (e) {
} catch (e) {
console.warn(`[AutoClip] live check failed for stream ${streamId}:`, e.message);
console.warn(`[AutoClip] live check failed for stream ${streamId}:`, e.message);
@@ -454,7 +454,7 @@ async function clipVodMoment(o) {
@@ -454,7 +454,7 @@ async function clipVodMoment(o) {
await _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: desc || '', speech, archival: archival || undefined, sig: _sceneSig(desc || title),
await _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, desc: desc || '', speech, archival: archival || undefined, sig: _sceneSig(desc || title),
score: rated.score, visibility: wanted, bucket: rated.bucket, peak: Number(sp.peak) || 0, chatters: evidence.chatters == null ? undefined : evidence.chatters,
score: rated.score, visibility: wanted, bucket: rated.bucket, peak: Number(sp.peak) || 0, chatters: evidence.chatters == null ? undefined : evidence.chatters,
picked_by: evidence.picked_by || undefined, policy: VOD_POLICY, verified: verified ? verified.verdict : undefined });
picked_by: evidence.picked_by || undefined, policy: VOD_POLICY, verified: verified ? verified.verdict : undefined });
- console.log(`[AutoClip] VOD ${vodId} @${Math.round(offset)}s cut (${evidence.picked_by || 'signals'}) — score ${rated.score} → ${wanted}${verified ? ` (check: ${verified.verdict})` : ''}, bucket ${rated.bucket}${cut.snapped ? ', on sentences' : ''}`);
+ console.log(`[AutoClip] VOD ${vodId} @${Math.round(offset)}s cut (${evidence.picked_by || 'signals'}) — score ${rated.score}, verdict ${wanted}${verified ? ` (check: ${verified.verdict})` : ''}, bucket ${rated.bucket}${cut.snapped ? ', on sentences' : ''}`);
}
}
return clip || null;
return clip || null;
} catch { return null; }
} catch { return null; }
@@ -224,7 +224,7 @@ async function threadsBlock(channelUserId, limit) {
@@ -224,7 +224,7 @@ async function threadsBlock(channelUserId, limit) {
rows,
rows,
text: rows.map(t => {
text: rows.map(t => {
let who = []; try { who = JSON.parse(t.participants_json || '[]'); } catch { /* */ }
let who = []; try { who = JSON.parse(t.participants_json || '[]'); } catch { /* */ }
- return `- thread #${t.id} (${t.kind.replace('_', '↔')}, ${who.join(' & ')}${t.topic ? `, about: ${clip(t.topic, 60)}` : ''}, ${t.turns} turns)${t.last_line ? ` last: ${t.last_line_by}: "${clip(t.last_line, 120)}"` : ''}${t.awaiting ? ` — waiting on ${t.awaiting}` : ''}`;
+ return `- thread #${t.id} (${t.kind.replace('_', ' to ')}, ${who.join(' & ')}${t.topic ? `, about: ${clip(t.topic, 60)}` : ''}, ${t.turns} turns)${t.last_line ? ` last: ${t.last_line_by}: "${clip(t.last_line, 120)}"` : ''}${t.awaiting ? ` — waiting on ${t.awaiting}` : ''}`;
}).join('\n'),
}).join('\n'),
};
};
}
}
@@ -37,7 +37,7 @@ function plainText(value) {
@@ -37,7 +37,7 @@ function plainText(value) {
}
}
text = db.publicMemoryText(text)
text = db.publicMemoryText(text)
.replace(/<[^>]*>/g, ' ')
.replace(/<[^>]*>/g, ' ')
- .replace(/-->/g, '→')
+ .replace(/-->/g, ' ')
.replace(/\s+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
.trim();
return text;
return text;
@@ -372,7 +372,7 @@ async function startContinuousCapture(stream) {
@@ -372,7 +372,7 @@ async function startContinuousCapture(stream) {
};
};
ff.on('close', onExit);
ff.on('close', onExit);
ff.on('error', (e) => onExit(e && e.message));
ff.on('error', (e) => onExit(e && e.message));
- if (!retry) console.log(`[AI-Hear] stream ${stream.id}: continuous capture started (${SEGMENT_SEC}s segments → ${dir})`);
+ if (!retry) console.log(`[AI-Hear] stream ${stream.id}: continuous capture started (${SEGMENT_SEC}s segments to ${dir})`);
return true;
return true;
}
}
@@ -206,7 +206,7 @@ let _interval = null;
@@ -206,7 +206,7 @@ let _interval = null;
function start() {
function start() {
if (_interval) return;
if (_interval) return;
_interval = setInterval(() => { tick().catch(() => {}); }, POLL_MS);
_interval = setInterval(() => { tick().catch(() => {}); }, POLL_MS);
- console.log('[AI] Timeline job started (continuous audio → stream_timeline_events)');
+ console.log('[AI] Timeline job started (continuous audio to stream_timeline_events)');
}
}
/**
/**
@@ -256,7 +256,7 @@ class AiViewersEngineV3 {
@@ -256,7 +256,7 @@ class AiViewersEngineV3 {
const accepted = await this._acceptPlan(w, res.plan, bots, consumed);
const accepted = await this._acceptPlan(w, res.plan, bots, consumed);
await poster.log(w, {
await poster.log(w, {
event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),
event: 'tick', reason: res.plan.notes || (res.plan.skip ? 'director: skip' : `planned ${accepted} line(s)`),
- text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot}→${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,
+ text: accepted ? res.plan.lines.slice(0, accepted).map(l => `${l.bot} to ${l.target}${l.reply_to ? '@' + l.reply_to : ''}`).join(', ') : null,
tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,
tokens_in: res.usage.input, tokens_cached: res.usage.cached, tokens_out: res.usage.output, cost_usd: res.cost, model: res.model,
});
});
for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { await db.closeAiViewerThread(n); } catch { /* */ } } }
for (const id of res.plan.threads_close || []) { const n = parseInt(id, 10); if (n) { try { await db.closeAiViewerThread(n); } catch { /* */ } } }
@@ -27,12 +27,12 @@ async function syncUsername(userId, newName) {
@@ -27,12 +27,12 @@ async function syncUsername(userId, newName) {
const user = await db.get('SELECT id, username FROM users WHERE id = ?', [userId]);
const user = await db.get('SELECT id, username FROM users WHERE id = ?', [userId]);
if (!user || String(user.username || '').toLowerCase() === newName.toLowerCase()) return false;
if (!user || String(user.username || '').toLowerCase() === newName.toLowerCase()) return false;
const clash = await db.get('SELECT id FROM users WHERE lower(username) = lower(?) AND id != ?', [newName, userId]);
const clash = await db.get('SELECT id FROM users WHERE lower(username) = lower(?) AND id != ?', [newName, userId]);
- if (clash) { console.warn(`[Usernames] ${user.username} → ${newName}: another Live user holds that name; not renamed`); return false; }
+ if (clash) { console.warn(`[Usernames] ${user.username} to ${newName}: another Live user holds that name; not renamed`); return false; }
await db.getDb().tx(async () => {
await db.getDb().tx(async () => {
await db.run('INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, ?, ?)', [user.id, user.username, newName]);
await db.run('INSERT INTO username_history (user_id, old_username, new_username) VALUES (?, ?, ?)', [user.id, user.username, newName]);
await db.run('UPDATE users SET username = ? WHERE id = ?', [newName, user.id]);
await db.run('UPDATE users SET username = ? WHERE id = ?', [newName, user.id]);
});
});
- console.log(`[Usernames] renamed ${user.username} → ${newName} (Network)`);
+ console.log(`[Usernames] renamed ${user.username} to ${newName} (Network)`);
return true;
return true;
}
}
@@ -80,7 +80,7 @@ function problems(env = process.env, { repoRoot = REPO_ROOT, cwd = process.cwd()
@@ -80,7 +80,7 @@ function problems(env = process.env, { repoRoot = REPO_ROOT, cwd = process.cwd()
const abs = path.resolve(cwd, value);
const abs = path.resolve(cwd, value);
const real = realish(abs);
const real = realish(abs);
const root = roots.find((r) => inside(abs, r) || inside(real, r));
const root = roots.find((r) => inside(abs, r) || inside(real, r));
- if (root) out.push(`${name} (${abs}${real !== abs ? ` → ${real}` : ''}) is inside ${root}; a drill writes nothing in the checkout or in production's data`);
+ if (root) out.push(`${name} (${abs}${real !== abs ? ` resolves to ${real}` : ''}) is inside ${root}; a drill writes nothing in the checkout or in production's data`);
};
};
// The database: the restored copy, never production's. Boot migrates through DATABASE_DIRECT_URL, so it must name
// The database: the restored copy, never production's. Boot migrates through DATABASE_DIRECT_URL, so it must name
// the copy too whenever DATABASE_URL does.
// the copy too whenever DATABASE_URL does.
@@ -111,7 +111,7 @@ function init({ eventsUrl = EVENTS_URL, clientSecret = CLIENT_SECRET, fetchImpl,
@@ -111,7 +111,7 @@ function init({ eventsUrl = EVENTS_URL, clientSecret = CLIENT_SECRET, fetchImpl,
outbox.start();
outbox.start();
pruneTimer = setInterval(() => { if (outbox) outbox.prune().catch(() => { /* next time */ }); }, PRUNE_EVERY_MS);
pruneTimer = setInterval(() => { if (outbox) outbox.prune().catch(() => { /* next time */ }); }, PRUNE_EVERY_MS);
if (pruneTimer.unref) pruneTimer.unref();
if (pruneTimer.unref) pruneTimer.unref();
- outbox.pending().then((n) => console.log(`[Events] stream lifecycle → ${eventsUrl} (${n} pending)`), () => {});
+ outbox.pending().then((n) => console.log(`[Events] stream lifecycle to ${eventsUrl} (${n} pending)`), () => {});
return outbox;
return outbox;
}
}
@@ -151,7 +151,7 @@ class ChatRelayService {
@@ -151,7 +151,7 @@ class ChatRelayService {
// every relayed message is duplicated (once per stale overlapping bridge).
// every relayed message is duplicated (once per stale overlapping bridge).
for (const [existingKey, b] of this.bridges) {
for (const [existingKey, b] of this.bridges) {
if (b.destId === dest.id && b.streamId !== streamId) {
if (b.destId === dest.id && b.streamId !== streamId) {
- console.log(`[ChatRelay] Replacing stale bridge ${existingKey} -> ${key} (same destination, prevents duplicate relay)`);
+ console.log(`[ChatRelay] Replacing stale bridge ${existingKey} with ${key} (same destination, prevents duplicate relay)`);
this._teardown(b);
this._teardown(b);
this.bridges.delete(existingKey);
this.bridges.delete(existingKey);
}
}
@@ -881,7 +881,7 @@ class ChatRelayService {
@@ -881,7 +881,7 @@ class ChatRelayService {
try {
try {
const current = await findLiveChatId();
const current = await findLiveChatId();
if (current && current !== liveChatId) {
if (current && current !== liveChatId) {
- console.log(`[ChatRelay] YouTube: live chat rotated for stream ${bridge.streamId} → ${current}`);
+ console.log(`[ChatRelay] YouTube: live chat rotated for stream ${bridge.streamId} to ${current}`);
liveChatId = current;
liveChatId = current;
bridge._ytPageToken = undefined;
bridge._ytPageToken = undefined;
bridge._ytFirstPoll = true; // don't dump the new chat's backlog
bridge._ytFirstPoll = true; // don't dump the new chat's backlog
@@ -149,7 +149,7 @@ async function postForm(url, formObj, headers = {}) {
@@ -149,7 +149,7 @@ async function postForm(url, formObj, headers = {}) {
try { json = text ? JSON.parse(text) : {}; } catch { /* non-json */ }
try { json = text ? JSON.parse(text) : {}; } catch { /* non-json */ }
if (!res.ok) {
if (!res.ok) {
const msg = (json && (json.message || json.error_description || json.error)) || text || res.statusText;
const msg = (json && (json.message || json.error_description || json.error)) || text || res.statusText;
- throw new Error(`${url} -> ${res.status}: ${msg}`);
+ throw new Error(`${url} returned ${res.status}: ${msg}`);
}
}
return json || {};
return json || {};
}
}
@@ -200,7 +200,7 @@ async function getJson(url, headers) {
@@ -200,7 +200,7 @@ async function getJson(url, headers) {
try { json = text ? JSON.parse(text) : {}; } catch { /* */ }
try { json = text ? JSON.parse(text) : {}; } catch { /* */ }
if (!res.ok) {
if (!res.ok) {
const msg = (json && (json.message || json.error?.message || json.error)) || text || res.statusText;
const msg = (json && (json.message || json.error?.message || json.error)) || text || res.statusText;
- throw new Error(`${url} -> ${res.status}: ${msg}`);
+ throw new Error(`${url} returned ${res.status}: ${msg}`);
}
}
return json || {};
return json || {};
}
}
@@ -286,7 +286,7 @@ async function createYouTubeStream(headers) {
@@ -286,7 +286,7 @@ async function createYouTubeStream(headers) {
body: JSON.stringify(body),
body: JSON.stringify(body),
});
});
const json = await res.json().catch(() => ({}));
const json = await res.json().catch(() => ({}));
- if (!res.ok) throw new Error(`YouTube liveStreams.insert -> ${res.status}: ${json.error?.message || res.statusText}`);
+ if (!res.ok) throw new Error(`YouTube liveStreams.insert returned ${res.status}: ${json.error?.message || res.statusText}`);
return json;
return json;
}
}
@@ -859,7 +859,7 @@ class RobotStreamerService {
@@ -859,7 +859,7 @@ class RobotStreamerService {
};
};
outgoing = JSON.stringify(msg);
outgoing = JSON.stringify(msg);
}
}
- console.log(`[RS Publish] Client → SFU: ${msg.method} (id=${msg.id}) | upstream ready: ${upstreamReady} | queued: ${outboundQueue.length}`);
+ console.log(`[RS Publish] Client to SFU: ${msg.method} (id=${msg.id}) | upstream ready: ${upstreamReady} | queued: ${outboundQueue.length}`);
}
}
if (!upstreamReady) outboundQueue.push(outgoing);
if (!upstreamReady) outboundQueue.push(outgoing);
@@ -992,11 +992,11 @@ class RobotStreamerService {
@@ -992,11 +992,11 @@ class RobotStreamerService {
const parsed = safeJsonParse(raw);
const parsed = safeJsonParse(raw);
if (parsed?.response) {
if (parsed?.response) {
const status = parsed.ok === false ? 'ERROR' : 'OK';
const status = parsed.ok === false ? 'ERROR' : 'OK';
- console.log(`[RS Publish] SFU → client: response id=${parsed.id} ${status}`, parsed.ok === false ? JSON.stringify({ error: parsed.error, reason: parsed.reason }) : '');
+ console.log(`[RS Publish] SFU to client: response id=${parsed.id} ${status}`, parsed.ok === false ? JSON.stringify({ error: parsed.error, reason: parsed.reason }) : '');
} else if (parsed?.request) {
} else if (parsed?.request) {
- console.log(`[RS Publish] SFU → client: request method=${parsed.method}`);
+ console.log(`[RS Publish] SFU to client: request method=${parsed.method}`);
} else if (parsed?.notification) {
} else if (parsed?.notification) {
- console.log(`[RS Publish] SFU → client: notification method=${parsed.method}`);
+ console.log(`[RS Publish] SFU to client: notification method=${parsed.method}`);
}
}
});
});
@@ -482,7 +482,7 @@ class RsPassthroughRelay {
@@ -482,7 +482,7 @@ class RsPassthroughRelay {
session.state = 'restarting';
session.state = 'restarting';
session.nextRestartAt = Date.now() + delay;
session.nextRestartAt = Date.now() + delay;
log(session.streamId, `⚠️ passthrough restart #${session.restarts}/${MAX_RESTARTS} in ${delay}ms — reason: ${reason} ` +
log(session.streamId, `⚠️ passthrough restart #${session.restarts}/${MAX_RESTARTS} in ${delay}ms — reason: ${reason} ` +
- `(RS viewers see this as the video cutting out: new SSRC → new consumer → client blanks the MediaStream)`);
+ `(RS viewers see this as the video cutting out: new SSRC, then a new consumer, then the client blanks the MediaStream)`);
session.restartTimer = setTimeout(async () => {
session.restartTimer = setTimeout(async () => {
session.restartTimer = null;
session.restartTimer = null;
if (session.stopped) return;
if (session.stopped) return;
@@ -632,7 +632,7 @@ class RsPassthroughRelay {
@@ -632,7 +632,7 @@ class RsPassthroughRelay {
const isH264 = /h264/i.test(videoIn.mimeType || 'video/VP8');
const isH264 = /h264/i.test(videoIn.mimeType || 'video/VP8');
const videoCodec = isH264 ? useH264({ payloadType: 103 }) : useVP8({ payloadType: 101 });
const videoCodec = isH264 ? useH264({ payloadType: 103 }) : useVP8({ payloadType: 101 });
const audioCodec = useOPUS({ payloadType: 100 });
const audioCodec = useOPUS({ payloadType: 100 });
- log(sid, `source video codec: ${videoIn.mimeType} → producing ${videoCodec.mimeType}`);
+ log(sid, `source video codec ${videoIn.mimeType}, producing ${videoCodec.mimeType}`);
const pc = new RTCPeerConnection({
const pc = new RTCPeerConnection({
codecs: { video: [videoCodec], audio: [audioCodec] },
codecs: { video: [videoCodec], audio: [audioCodec] },
headerExtensions: {
headerExtensions: {
@@ -1040,7 +1040,7 @@ class RsPassthroughRelay {
@@ -1040,7 +1040,7 @@ class RsPassthroughRelay {
// RS was told one codec; a source that changed codec needs a new RS producer (a restart).
// RS was told one codec; a source that changed codec needs a new RS producer (a restart).
if (kind === 'video' && String(next.mimeType).toLowerCase() !== String(videoIn.mimeType).toLowerCase()) {
if (kind === 'video' && String(next.mimeType).toLowerCase() !== String(videoIn.mimeType).toLowerCase()) {
closeIngest(next);
closeIngest(next);
- log(sid, `source video codec changed (${videoIn.mimeType} → ${next.mimeType}) — restarting for a new RS producer`);
+ log(sid, `source video codec changed (${videoIn.mimeType} to ${next.mimeType}) — restarting for a new RS producer`);
this._teardown(session);
this._teardown(session);
this._scheduleRestart(session, 'source video codec changed').catch(err => log(sid, 'restart check error:', err.message));
this._scheduleRestart(session, 'source video codec changed').catch(err => log(sid, 'restart check error:', err.message));
return false;
return false;
@@ -1050,7 +1050,7 @@ class RsPassthroughRelay {
@@ -1050,7 +1050,7 @@ class RsPassthroughRelay {
session.ingests = session.ingests.filter(i => i !== cur).concat(next);
session.ingests = session.ingests.filter(i => i !== cur).concat(next);
closeIngest(cur);
closeIngest(cur);
stats.switches = (stats.switches || 0) + 1;
stats.switches = (stats.switches || 0) + 1;
- log(sid, `↪ ${kind} now from producer ${producer.id} (${why}) — same RS producer and SSRC, no restart`);
+ log(sid, `switched: ${kind} now from producer ${producer.id} (${why}) — same RS producer and SSRC, no restart`);
if (kind === 'video') { reqKeyNow(0); setTimeout(() => reqKeyNow(250), 400); }
if (kind === 'video') { reqKeyNow(0); setTimeout(() => reqKeyNow(250), 400); }
return true;
return true;
} finally { _switching = false; }
} finally { _switching = false; }
@@ -1143,7 +1143,7 @@ class RsPassthroughRelay {
@@ -1143,7 +1143,7 @@ class RsPassthroughRelay {
`aIn ${Math.round((stats.aIn - lastAIn) / 10)}/s aOut ${Math.round((stats.aOut - lastAOut) / 10)}/s${stalled} ` +
`aIn ${Math.round((stats.aIn - lastAIn) / 10)}/s aOut ${Math.round((stats.aOut - lastAOut) / 10)}/s${stalled} ` +
`| writeErr v=${stats.vErr} a=${stats.aErr} alienSsrc v=${stats.vAlien} a=${stats.aAlien} | stalls=${stats.ingestStalls} (${Math.round(stats.ingestStallMs / 1000)}s total) ` +
`| writeErr v=${stats.vErr} a=${stats.aErr} alienSsrc v=${stats.vAlien} a=${stats.aAlien} | stalls=${stats.ingestStalls} (${Math.round(stats.ingestStallMs / 1000)}s total) ` +
`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj}${INJECT_PARAMS ? '' : '(off)'} gopDmg=${stats.gopDamaged} ` +
`| kf ${stats.kf - lastKf}/10s maxKfGap ${stats.maxKfGap}ms injSPS/PPS=${stats.inj}${INJECT_PARAMS ? '' : '(off)'} gopDmg=${stats.gopDamaged} ` +
- `| lossIN ${stats.lostIn - lastLostIn} (goosely→relay) lateFill ${stats.lateFill - lastLateFill} ` +
+ `| lossIN ${stats.lostIn - lastLostIn} (goosely to relay) lateFill ${stats.lateFill - lastLateFill} ` +
`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} deepRtx=${stats.rtxDeep} rtxMiss=${stats.rtxMiss} rtxTooOld=${stats.rtxTooOld} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +
`| RS lost=${rsLostPct}% pktsLost=${s.remotePacketsLost ?? '?'} pliRx=${s.pliCount ?? '?'} firRx=${s.firCount ?? '?'} nackRx=${s.nackCount ?? '?'} retx=${s.retransmittedPacketsSent ?? '?'} deepRtx=${stats.rtxDeep} rtxMiss=${stats.rtxMiss} rtxTooOld=${stats.rtxTooOld} rtt=${Math.round((s.rtt || 0) * 1000)}ms ` +
`| rsRemb=${remb} bwe=${bwe} | keyReq=${stats.pli} werift=${pc.connectionState} dtls/ice=${vSenderState()}`);
`| rsRemb=${remb} bwe=${bwe} | keyReq=${stats.pli} werift=${pc.connectionState} dtls/ice=${vSenderState()}`);
lastVIn = stats.vIn; lastVOut = stats.vOut; lastAIn = stats.aIn; lastAOut = stats.aOut;
lastVIn = stats.vIn; lastVOut = stats.vOut; lastAIn = stats.aIn; lastAOut = stats.aOut;
@@ -291,7 +291,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -291,7 +291,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
priority: 'normal',
priority: 'normal',
title: parentId ? `New reply on your ${target.label}` : `New comment on your ${target.label}`,
title: parentId ? `New reply on your ${target.label}` : `New comment on your ${target.label}`,
message: `${actor.sender_name} ${parentId ? 'replied on' : 'commented on'} "${truncatePreview(target.title, 80)}"`,
message: `${actor.sender_name} ${parentId ? 'replied on' : 'commented on'} "${truncatePreview(target.title, 80)}"`,
- icon: parentId ? '↩️' : '💬',
+ icon: '💬',
...actor,
...actor,
service: 'live',
service: 'live',
url: `${target.url}#comments`,
url: `${target.url}#comments`,
@@ -311,7 +311,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -311,7 +311,7 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
priority: 'normal',
priority: 'normal',
title: 'New reply to your comment',
title: 'New reply to your comment',
message: `${actor.sender_name} replied to your comment on "${truncatePreview(target.title, 80)}"`,
message: `${actor.sender_name} replied to your comment on "${truncatePreview(target.title, 80)}"`,
- icon: '↩️',
+ icon: '💬',
...actor,
...actor,
service: 'live',
service: 'live',
url: `${target.url}#comments`,
url: `${target.url}#comments`,
@@ -89,7 +89,7 @@ function toCommunity(subPath, whenDown = null) {
@@ -89,7 +89,7 @@ function toCommunity(subPath, whenDown = null) {
}
}
res.send(Buffer.from(await upstream.arrayBuffer()));
res.send(Buffer.from(await upstream.arrayBuffer()));
} catch (err) {
} catch (err) {
- console.warn('[Pastes → Community]', err.message);
+ console.warn('[Pastes to Community]', err.message);
if (!res.headersSent) {
if (!res.headersSent) {
if (whenDown) return res.json(whenDown(req));
if (whenDown) return res.json(whenDown(req));
res.status(502).json({ error: 'Paste service unavailable' });
res.status(502).json({ error: 'Paste service unavailable' });
@@ -115,7 +115,7 @@ communityRouter.post('/:slug/set-avatar', requireAuth, async (req, res) => {
@@ -115,7 +115,7 @@ communityRouter.post('/:slug/set-avatar', requireAuth, async (req, res) => {
} catch { /* next report */ }
} catch { /* next report */ }
res.json({ success: true, avatar_url: stored });
res.json({ success: true, avatar_url: stored });
} catch (err) {
} catch (err) {
- console.warn('[Pastes → Community] set-avatar:', err.message);
+ console.warn('[Pastes to Community] set-avatar:', err.message);
res.status(500).json({ error: 'Failed to set avatar' });
res.status(500).json({ error: 'Failed to set avatar' });
}
}
});
});
@@ -35,19 +35,19 @@ async function handle(chat, ws, client, cmd, parts) {
@@ -35,19 +35,19 @@ async function handle(chat, ws, client, cmd, parts) {
if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }
if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }
const work = (async () => {
const work = (async () => {
try {
try {
- if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead → ${base()}/rundown`);
+ if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead: ${base()}/rundown`);
const stream = client.streamId ? await db.getStreamById(client.streamId) : null;
const stream = client.streamId ? await db.getStreamById(client.streamId) : null;
const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')])
const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')])
: (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);
: (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);
- if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up → ${base()}/rundown`);
+ if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up at ${base()}/rundown`);
const read = require('./read');
const read = require('./read');
const page = await read.streamer(target.username);
const page = await read.streamer(target.username);
const name = target.display_name || target.username;
const name = target.display_name || target.username;
if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);
if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);
const ep = page.episodes[0];
const ep = page.episodes[0];
const topics = page.topics.slice(0, 3).map((t) => t.title);
const topics = page.topics.slice(0, 3).map((t) => t.title);
- if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream → ${base()}/rundown`);
- return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} → ${base()}/rundown/${encodeURIComponent(target.username)}`);
+ if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream. See ${base()}/rundown`);
+ return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} at ${base()}/rundown/${encodeURIComponent(target.username)}`);
} catch (err) {
} catch (err) {
reply(`The Rundown: ${err.message}`);
reply(`The Rundown: ${err.message}`);
}
}
@@ -415,7 +415,7 @@ async function announce(stream, user, title) {
@@ -415,7 +415,7 @@ async function announce(stream, user, title) {
let base = 'https://openvibe.live';
let base = 'https://openvibe.live';
try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }
try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }
const url = `${base}/rundown/e/${stream.id}`;
const url = `${base}/rundown/e/${stream.id}`;
- await require('../chat/chat-delivery').message({ username: 'THE RUNDOWN', user_id: null, message: `📰 ${user.display_name || user.username}: “${title}” → ${url}`.slice(0, 480), message_type: 'system', key: `rundown:episode:${stream.id}`, metadata: { rundown_episode: stream.id, url }, is_global: true });
+ await require('../chat/chat-delivery').message({ username: 'THE RUNDOWN', user_id: null, message: `📰 ${user.display_name || user.username}: “${title}” at ${url}`.slice(0, 480), message_type: 'system', key: `rundown:episode:${stream.id}`, metadata: { rundown_episode: stream.id, url }, is_global: true });
return true;
return true;
}
}
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
'use strict';
'use strict';
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const glyphs = require('openvibe-shared/glyphs');
const { esc } = seo;
const { esc } = seo;
const { setRobotsHeader } = require('../web/robots-header');
const { setRobotsHeader } = require('../web/robots-header');
const db = require('../db/database');
const db = require('../db/database');
@@ -278,7 +279,7 @@ async function _channelSectionMeta(username, section, page) {
@@ -278,7 +279,7 @@ async function _channelSectionMeta(username, section, page) {
const hasNext = pages ? page < pages : rows.length === CHANNEL_PAGE_SIZE;
const hasNext = pages ? page < pages : rows.length === CHANNEL_PAGE_SIZE;
const pager = `<nav class="pagination" aria-label="Pages">${page > 1 ? `<a rel="prev" href="${href(page - 1)}">Previous</a>` : ''}<span>Page ${page}${pages ? ` of ${pages}` : ''}</span>${hasNext ? `<a rel="next" href="${href(page + 1)}">Next</a>` : ''}</nav>`;
const pager = `<nav class="pagination" aria-label="Pages">${page > 1 ? `<a rel="prev" href="${href(page - 1)}">Previous</a>` : ''}<span>Page ${page}${pages ? ` of ${pages}` : ''}</span>${hasNext ? `<a rel="next" href="${href(page + 1)}">Next</a>` : ''}</nav>`;
const list = items.length ? `<ol>${items.map((item) => `<li><a href="${esc(abs(item.url))}">${esc(clean(item.name, 120))}</a><time>${esc(item.meta)}</time></li>`).join('')}</ol>` : '<p>No items on this page.</p>';
const list = items.length ? `<ol>${items.map((item) => `<li><a href="${esc(abs(item.url))}">${esc(clean(item.name, 120))}</a><time>${esc(item.meta)}</time></li>`).join('')}</ol>` : '<p>No items on this page.</p>';
- const snapshot = `<p><a href="${esc(abs(`/@${acct.username}`))}">← ${esc(name)}'s channel</a></p><h1>${esc(name)} · ${esc(title)}</h1>${list}${pager}`;
+ const snapshot = `<p><a href="${esc(abs(`/@${acct.username}`))}">${glyphs.svg('arrow-left', { size: 16 })} ${esc(name)}'s channel</a></p><h1>${esc(name)} · ${esc(title)}</h1>${list}${pager}`;
return {
return {
title: `${name} — ${title}, page ${page} | ${SITE_NAME}`, description: `Browse ${name}'s ${title.toLowerCase()} on ${SITE_NAME}.`,
title: `${name} — ${title}, page ${page} | ${SITE_NAME}`, description: `Browse ${name}'s ${title.toLowerCase()} on ${SITE_NAME}.`,
canonicalPath, robots: page > 1 && !rows.length ? 'noindex,follow' : 'index,follow',
canonicalPath, robots: page > 1 && !rows.length ? 'noindex,follow' : 'index,follow',
@@ -235,7 +235,7 @@ class StreamRecorder {
@@ -235,7 +235,7 @@ class StreamRecorder {
const rtmpUrl = `rtmp://127.0.0.1:${config.rtmp.port}/live/${endpoint.streamKey}`;
const rtmpUrl = `rtmp://127.0.0.1:${config.rtmp.port}/live/${endpoint.streamKey}`;
await media.ingestRtmp(rec.vodId, rtmpUrl);
await media.ingestRtmp(rec.vodId, rtmpUrl);
this._partStarted(stream, rec);
this._partStarted(stream, rec);
- console.log(`[VOD] Media RTMP ingest started: stream ${streamId} → vod ${rec.vodId} (${rec.mode})`);
+ console.log(`[VOD] Media RTMP ingest started: stream ${streamId} to vod ${rec.vodId} (${rec.mode})`);
}
}
async _startRtpRecording(streamId, stream, rec) {
async _startRtpRecording(streamId, stream, rec) {
@@ -342,7 +342,7 @@ class StreamRecorder {
@@ -342,7 +342,7 @@ class StreamRecorder {
rec.webrtcState = webrtcState;
rec.webrtcState = webrtcState;
this._partStarted(stream, rec);
this._partStarted(stream, rec);
- console.log(`[VOD] Media RTP ingest started: stream ${streamId} → vod ${rec.vodId} (${videoPort ? `v:${videoPort}` : 'audio only'}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);
+ console.log(`[VOD] Media RTP ingest started: stream ${streamId} to vod ${rec.vodId} (${videoPort ? `v:${videoPort}` : 'audio only'}${audioPort ? ` a:${audioPort}` : ''}, ${rec.mode})`);
}
}
/**
/**
@@ -425,7 +425,7 @@ class StreamRecorder {
@@ -425,7 +425,7 @@ class StreamRecorder {
await media.deleteVod(rec.vodId).catch(() => {});
await media.deleteVod(rec.vodId).catch(() => {});
console.log(`[VOD] Discarded ephemeral clips-only recording (vod ${rec.vodId}, stream ${streamId})`);
console.log(`[VOD] Discarded ephemeral clips-only recording (vod ${rec.vodId}, stream ${streamId})`);
} else {
} else {
- console.log(`[VOD] Recording stopped: stream ${streamId} → vod ${rec.vodId} (Media finalizing)`);
+ console.log(`[VOD] Recording stopped: stream ${streamId} to vod ${rec.vodId} (Media finalizing)`);
}
}
})();
})();
finish.catch(() => {}).finally(() => this._finalizing.delete(streamId));
finish.catch(() => {}).finally(() => this._finalizing.delete(streamId));
@@ -326,7 +326,7 @@ class RestreamManager extends EventEmitter {
@@ -326,7 +326,7 @@ class RestreamManager extends EventEmitter {
if (protocol === 'webrtc') {
if (protocol === 'webrtc') {
const webrtcSFU = require('./webrtc-sfu');
const webrtcSFU = require('./webrtc-sfu');
if (!webrtcSFU.ready) {
if (!webrtcSFU.ready) {
- console.warn('[Restream] WebRTC → RTMP requires Mediasoup SFU. Mediasoup not available.');
+ console.warn('[Restream] WebRTC to RTMP requires Mediasoup SFU. Mediasoup not available.');
return null;
return null;
}
}
}
}
@@ -532,7 +532,7 @@ class RestreamManager extends EventEmitter {
@@ -532,7 +532,7 @@ class RestreamManager extends EventEmitter {
}
}
/**
/**
- * Start WebRTC → RTMP restream.
+ * Start WebRTC to RTMP restream.
*
*
* Flow:
* Flow:
* 1. Signal the broadcaster to produce tracks into Mediasoup SFU
* 1. Signal the broadcaster to produce tracks into Mediasoup SFU
@@ -413,11 +413,11 @@ router.post('/destinations/:id/start', requireAuth, async (req, res) => {
@@ -413,11 +413,11 @@ router.post('/destinations/:id/start', requireAuth, async (req, res) => {
}
}
if (stream.protocol === 'webrtc') {
if (stream.protocol === 'webrtc') {
- // WebRTC → RTMP requires Mediasoup SFU
+ // WebRTC to RTMP requires Mediasoup SFU
const webrtcSFU = require('./webrtc-sfu');
const webrtcSFU = require('./webrtc-sfu');
if (!webrtcSFU.ready) {
if (!webrtcSFU.ready) {
return res.status(400).json({
return res.status(400).json({
- error: 'WebRTC → RTMP restreaming requires Mediasoup. Install mediasoup: npm install mediasoup',
+ error: 'WebRTC to RTMP restreaming requires Mediasoup. Install mediasoup: npm install mediasoup',
});
});
}
}
}
}
@@ -650,7 +650,7 @@ router.get('/oauth/:platform/start', requireAuth, async (req, res) => {
@@ -650,7 +650,7 @@ router.get('/oauth/:platform/start', requireAuth, async (req, res) => {
function oauthErrorText(platform, err, description) {
function oauthErrorText(platform, err, description) {
const name = (platformOAuth.PLATFORMS[platform] && platformOAuth.PLATFORMS[platform].name) || String(platform).charAt(0).toUpperCase() + String(platform).slice(1);
const name = (platformOAuth.PLATFORMS[platform] && platformOAuth.PLATFORMS[platform].name) || String(platform).charAt(0).toUpperCase() + String(platform).slice(1);
if (/redirect/i.test(String(err || '')) || /redirect[_ ]?ur[il]/i.test(String(description || ''))) {
if (/redirect/i.test(String(err || '')) || /redirect[_ ]?ur[il]/i.test(String(description || ''))) {
- return `${name} refused the sign-in because this site's address is not on its app's list. In the ${name} developer console, add ${platformOAuth.redirectUri(platform)} as an OAuth redirect URL for the app whose client ID is set in Admin → Integrations, then connect again. Until then, paste the stream key into the destination by hand.`;
+ return `${name} refused the sign-in because this site's address is not on its app's list. In the ${name} developer console, add ${platformOAuth.redirectUri(platform)} as an OAuth redirect URL for the app whose client ID is set in Admin, under Integrations, then connect again. Until then, paste the stream key into the destination by hand.`;
}
}
return description || err;
return description || err;
}
}
@@ -477,8 +477,8 @@ npm run init-db</code></pre>
@@ -477,8 +477,8 @@ npm run init-db</code></pre>
<h3>Main website</h3>
<h3>Main website</h3>
<p>Create these records in Cloudflare DNS:</p>
<p>Create these records in Cloudflare DNS:</p>
<ul>
<ul>
-<li><code>A</code> record for <code>@</code> → <code>YOUR_OVH_PUBLIC_IP</code></li>
-<li><code>CNAME</code> for <code>www</code> → your apex hostname</li>
+<li><code>A</code> record for <code>@</code> pointing at <code>YOUR_OVH_PUBLIC_IP</code></li>
+<li><code>CNAME</code> for <code>www</code> pointing at your apex hostname</li>
</ul>
</ul>
<p>Set both to <strong>Proxied</strong> (orange cloud) after the origin is working.</p>
<p>Set both to <strong>Proxied</strong> (orange cloud) after the origin is working.</p>
@@ -1031,18 +1031,18 @@ sudo ufw status verbose</code></pre>
@@ -1031,18 +1031,18 @@ sudo ufw status verbose</code></pre>
<h3>Approximate retained recording hours (at 2.5 Mbps)</h3>
<h3>Approximate retained recording hours (at 2.5 Mbps)</h3>
<ul>
<ul>
-<li>30 GB usable → ~27 hours</li>
-<li>80 GB usable → ~73 hours</li>
-<li>180 GB usable → ~164 hours</li>
-<li>380 GB usable → ~345 hours</li>
+<li>30 GB usable, about 27 hours</li>
+<li>80 GB usable, about 73 hours</li>
+<li>180 GB usable, about 164 hours</li>
+<li>380 GB usable, about 345 hours</li>
</ul>
</ul>
<h3>At 4.0 Mbps</h3>
<h3>At 4.0 Mbps</h3>
<ul>
<ul>
-<li>30 GB usable → ~17 hours</li>
-<li>80 GB usable → ~45 hours</li>
-<li>180 GB usable → ~102 hours</li>
-<li>380 GB usable → ~216 hours</li>
+<li>30 GB usable, about 17 hours</li>
+<li>80 GB usable, about 45 hours</li>
+<li>180 GB usable, about 102 hours</li>
+<li>380 GB usable, about 216 hours</li>
</ul>
</ul>
<div class="card tip"><strong>Storage takeaway:</strong> 50 GB is test-only. 100 GB is okay for dev/alpha. <strong>200 GB</strong> is the first production-usable size. 400 GB is much better but still not archival. Long-term, push older VODs to object storage.</div>
<div class="card tip"><strong>Storage takeaway:</strong> 50 GB is test-only. 100 GB is okay for dev/alpha. <strong>200 GB</strong> is the first production-usable size. 400 GB is much better but still not archival. Long-term, push older VODs to object storage.</div>
@@ -31,11 +31,17 @@ assert.strictEqual(old.kind, 'live'); assert.strictEqual(old.name, 'AmberFieraGa
@@ -31,11 +31,17 @@ assert.strictEqual(old.kind, 'live'); assert.strictEqual(old.name, 'AmberFieraGa
const fromMeta = _chatEventOf({ message_type: 'system', message: 'something else', metadata: { kind: 'live_link', url: 'https://openvibe.live/@iZoR_One' } });
const fromMeta = _chatEventOf({ message_type: 'system', message: 'something else', metadata: { kind: 'live_link', url: 'https://openvibe.live/@iZoR_One' } });
assert.strictEqual(fromMeta.name, 'iZoR_One', 'the name comes from the link when the text is not the usual one');
assert.strictEqual(fromMeta.name, 'iZoR_One', 'the name comes from the link when the text is not the usual one');
-const rd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Second McDoanld\'s Run: Bug Fix Later” → https://openvibe.live/rundown/e/2891',
+const rd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Second McDoanld\'s Run: Bug Fix Later” at https://openvibe.live/rundown/e/2891',
metadata: { rundown_episode: 2891, url: 'https://openvibe.live/rundown/e/2891' } });
metadata: { rundown_episode: 2891, url: 'https://openvibe.live/rundown/e/2891' } });
assert.deepStrictEqual({ kind: rd.kind, name: rd.name, title: rd.title, url: rd.url },
assert.deepStrictEqual({ kind: rd.kind, name: rd.name, title: rd.title, url: rd.url },
{ kind: 'rundown', name: 'goosely', title: "Second McDoanld's Run: Bug Fix Later", url: 'https://openvibe.live/rundown/e/2891' });
{ kind: 'rundown', name: 'goosely', title: "Second McDoanld's Run: Bug Fix Later", url: 'https://openvibe.live/rundown/e/2891' });
+// Rows posted before the wording changed still read as the same card (the parser keeps the old separator as an escape).
+const oldRd = _chatEventOf({ message_type: 'system', message: '\u{1F4F0} goosely: “Old episode” \u2192 https://openvibe.live/rundown/e/2890',
+ metadata: { rundown_episode: 2890, url: 'https://openvibe.live/rundown/e/2890' } });
+assert.deepStrictEqual({ kind: oldRd.kind, name: oldRd.name, title: oldRd.title, url: oldRd.url },
+ { kind: 'rundown', name: 'goosely', title: 'Old episode', url: 'https://openvibe.live/rundown/e/2890' });
+
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: https://evil.example/@x' }), null, 'never a link to another site');
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: https://evil.example/@x' }), null, 'never a link to another site');
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: javascript:alert(1)' }), null);
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '\u{1F534} x is live. Share the stream: javascript:alert(1)' }), null);
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '🚀 1 update shipped' }), null, 'a deploy row is not an event');
assert.strictEqual(_chatEventOf({ message_type: 'system', message: '🚀 1 update shipped' }), null, 'a deploy row is not an event');
@@ -39,7 +39,7 @@ assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);
@@ -39,7 +39,7 @@ assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);
await run({ anonId: 'zz', streamId: liveId }, '!beef');
await run({ anonId: 'zz', streamId: liveId }, '!beef');
assert.match(sent.pop(), /Beefs are gone/);
assert.match(sent.pop(), /Beefs are gone/);
await run({ anonId: 'zy', streamId: liveId }, '!arena');
await run({ anonId: 'zy', streamId: liveId }, '!arena');
-assert.match(sent.pop(), /^📰 Nova — latest episode: “The Fridge Files” · talks about Fridges → .*\/rundown\/nova$/, 'the streamer whose chat it is');
+assert.match(sent.pop(), /^📰 Nova — latest episode: “The Fridge Files” · talks about Fridges at .*\/rundown\/nova$/, 'the streamer whose chat it is');
await run({ anonId: 'zx', streamId: liveId }, '!arena quietguy');
await run({ anonId: 'zx', streamId: liveId }, '!arena quietguy');
assert.match(sent.pop(), /Nothing on The Rundown about Quietguy yet/);
assert.match(sent.pop(), /Nothing on The Rundown about Quietguy yet/);
await run({ anonId: 'zw', streamId: liveId }, '!arena nobody_here');
await run({ anonId: 'zw', streamId: liveId }, '!arena nobody_here');
@@ -126,7 +126,7 @@ assert.strictEqual(ep.title, 'The Fridge Files');
@@ -126,7 +126,7 @@ assert.strictEqual(ep.title, 'The Fridge Files');
assert.strictEqual(ep.recap, 'goosely opened with French fridges.\n\nThen ranked.');
assert.strictEqual(ep.recap, 'goosely opened with French fridges.\n\nThen ranked.');
assert.deepStrictEqual(JSON.parse(ep.chapters_json).map((c) => [c.title, Number(c.segment_id)]), [['Fridges', Number(segs[0].id)], ['Ranked', Number(segs[1].id)]], 'chapters name real segments');
assert.deepStrictEqual(JSON.parse(ep.chapters_json).map((c) => [c.title, Number(c.segment_id)]), [['Fridges', Number(segs[0].id)], ['Ranked', Number(segs[1].id)]], 'chapters name real segments');
assert.strictEqual(announced.length, 1);
assert.strictEqual(announced.length, 1);
-assert.match(announced[0].message, /^📰 goosely: “The Fridge Files” → .*\/rundown\/e\/\d+$/);
+assert.match(announced[0].message, /^📰 goosely: “The Fridge Files” at .*\/rundown\/e\/\d+$/);
assert.strictEqual((await db.get('SELECT done FROM rundown_progress WHERE stream_id = ?', [s1])).done, 1);
assert.strictEqual((await db.get('SELECT done FROM rundown_progress WHERE stream_id = ?', [s1])).done, 1);
const before = calls.length;
const before = calls.length;
await desk.tick();
await desk.tick();