# OpenVibe.Live > Open-source, community-run live streaming. Anyone can go live straight from a web browser, with no OBS, no downloads and no follower, subscriber or equipment requirement: press Go Live, allow camera and microphone, and the stream is live (WebRTC, under a second of delay; Chromebooks, laptops and phones). OBS (RTMP), WHIP and command-line ingest are there for produced shows. Every stream can be recorded as a VOD, clipped and discussed. Part of the OpenVibe network: one account across openvibe.network, openvibe.media, openvibe.community, openvibe.tools and the other OpenVibe sites. ## Go live from a browser: no OBS, no downloads, no follower minimum (how, devices, questions) https://openvibe.live/docs/go-live-in-your-browser # Go live from your browser: no OBS, no downloads, no follower minimum You can start a live stream on OpenVibe.Live from a web browser, with nothing to install and no follower, subscriber or equipment requirement. Open the site, press **Go Live**, allow your camera and microphone, and you are live. It works on a Chromebook, a laptop or a phone. OpenVibe.Live is open source and run by its community. ## How to go live in your browser 1. Open [openvibe.live](https://openvibe.live) in Chrome, Edge, Firefox or Safari. 2. Sign in, or make an account. It takes a minute, and your channel exists as soon as you do (`openvibe.live/@yourname`). 3. Press **Go Live** in the top bar. 4. Allow your camera and microphone when the browser asks. 5. Give the stream a title and press **Start**. You are live, with your own channel page and chat. ## What you need - **A modern browser:** Chrome, Edge, Firefox or Safari, on Windows, macOS, Linux, ChromeOS, Android or iOS. - **A camera and a microphone:** the ones built into your laptop or phone are enough. You can also share your screen instead of a camera. - **An OpenVibe account.** You don't need any of the following: - **Software:** no OBS, Streamlabs or plug-ins. - **An audience:** no minimum number of followers or subscribers, and no history of past streams. - **Equipment:** no capture card or gaming PC. ## What you can do while live - **Flip cameras:** front and back on a phone in one tap, or choose any connected webcam. - **Share your screen,** a window or a tab. You can also show your camera in a corner over it. - **Mute** your microphone or turn off your camera without ending the stream. - **Watch your stream's health:** bitrate, frame rate and resolution. - **Chat with viewers** on the same page. Your stream can be kept as a VOD, and people can clip it. **On a phone,** going live turns the screen into a live studio, the way phone streaming apps work: - The camera fills the screen, and chat runs over the bottom of the picture, where you can read and answer it. - The top shows LIVE, how long you have been live, how many people are watching, your restreams and your connection. - The buttons on the right are Flip (front and back camera), Mute, Camera, Light (a back camera's flash), Chat (hide or show it), Clip (the last 30 seconds) and More. - **More** opens copy link, watch as a viewer, choose any camera, media requests, a one-minute clip, chat read aloud, your restreams, stream health, all settings and the full page. - **End** asks before it ends the stream. - Keep the OpenVibe tab open and in front while you are live. A phone pauses the camera in other tabs and apps, and the stream ends after a minute away, so "Watch as a viewer" opens over the studio instead of in a new tab. Your stream reaches viewers over **WebRTC**, with less than a second of delay. That is fast enough for a real conversation with chat, and for viewers to control a robot or camera in real time. ## When to use OBS instead The browser is the fastest way to start. For a produced show with scenes, overlays and several sources, OpenVibe.Live also takes **OBS or any RTMP encoder**, **WHIP** (OBS 30+, ffmpeg, GStreamer or your own web page) and **ffmpeg** from a command line, such as from a Raspberry Pi. See [Broadcasting](broadcasting.md) for every method, and the [WHIP ingest API](whip.md) to publish from your own site. ## Questions **Can I stream without OBS?** Yes. OpenVibe.Live has a browser broadcaster built in. Press Go Live and allow your camera and microphone. **Do I need followers or subscribers to go live?** No. Every account can go live from the first minute. There is no follower, subscriber, age-of-account or past-stream requirement. **Can I stream from a Chromebook or a phone?** Yes. The broadcaster runs in the browser, so it works on ChromeOS, Android and iOS as well as Windows, macOS and Linux. **Can I share my screen?** Yes. Share a screen, a window or a browser tab, with or without your camera over it. **Is there a delay?** Browser broadcasts use WebRTC, with less than a second of delay. **Is it open source?** Yes. The code is at [github.com/OpenVibers/OpenVibe.Live](https://github.com/OpenVibers/OpenVibe.Live), and the platform is part of the OpenVibe network: one account works across all of its sites. ## WHIP ingest API: publish to a channel from a browser or any WHIP client https://openvibe.live/docs/whip # WHIP Ingest API OpenVibe.Live accepts live video over **WHIP** (WebRTC-HTTP Ingestion Protocol, [RFC 9725](https://www.rfc-editor.org/rfc/rfc9725)). Anything that can produce a WebRTC offer can go live with a single HTTP request — OBS 30+, FFmpeg 7+, GStreamer, hardware encoders, **and a plain web page**. > **Can a frontend-only site stream to WHIP?** Yes. The endpoint is open to every origin (CORS `*`) and authenticates with your stream key, so a static page with no backend can publish straight from the browser using only `RTCPeerConnection` + `fetch()`. Jump to [Publishing from a browser](#publishing-from-a-browser), or try the hosted single-file demo at **** (view-source to copy it). ## Endpoint ``` POST https://whip.openvibe.live/whip/{streamKey} Content-Type: application/sdp ``` | URL form | Credential | Use it when | |---|---|---| | `/whip/{streamKey}` | the key is in the path (optionally also as `Authorization: Bearer {streamKey}` — OBS sends both) | **Default.** Browsers, FFmpeg, GStreamer, OBS. | | `/whip/{slotId}` | `Authorization: Bearer {streamKey}` or `?key={streamKey}` | Tools that want a stable, non-secret URL with the secret in a header. | | `/whip/{streamSessionId}` | `Authorization: Bearer {openvibe.network JWT}` | Internal / legacy. Requires an already-live session; `hbt_` API tokens are **not** accepted here. | `{streamKey}` is a 32-char hex string that belongs to one **stream slot** (a "managed stream"). Posting to it auto-creates a live session on that slot if none exists — you do not need to click *Go Live* first. The slot's streaming method must be **Browser** or **WHIP** (protocol `webrtc`); RTMP/JSMPEG slots answer `409 wrong_protocol`. ### Getting your WHIP URL - **UI:** *Broadcast → your stream → Streaming method: WHIP* → copy the WHIP URL. (Same URL the OBS instructions show.) - **API:** `GET /api/streams/managed/{slotId}/profile` (JWT or `hbt_` token) returns `stream_key` and `whip_url_base`; the URL is `${whip_url_base}/whip/${stream_key}`. Treat the URL as a password: anyone holding it can broadcast to your channel. Regenerate the key from the dashboard if it leaks. ## Session lifecycle 1. **`POST /whip/{streamKey}`** with the SDP offer as the body. `201 Created` · body is the SDP answer (`Content-Type: application/sdp`) · `Location` header is the **session resource URL** — keep it. 2. Apply the answer, and ICE/DTLS connect. The stream is shown as live once media flows (the *Live* state in your app corresponds to `RTCPeerConnection.connectionState === 'connected'`). 3. **`PATCH {Location}`** (`Content-Type: application/trickle-ice-sdpfrag`) — accepted and acknowledged with `204`, but **not required**: the server is an ICE-lite agent and includes all of its candidates in the answer, so a client never needs to trickle. Browsers can post the offer immediately after `setLocalDescription()` without waiting for gathering. 4. **`DELETE {Location}`** — ends the session. When the last session on a stream is gone, the stream ends (VOD finalises, restreams stop, viewers are notified). Sessions also end on their own if ICE fails, or 15 s after ICE reports `disconnected` without recovering. Posting a second offer to the same slot replaces the previous session (useful for reconnecting after a network change). ## Media requirements | | Accepted | Notes | |---|---|---| | Video | **VP8**, **H.264** (Constrained Baseline, `profile-level-id=42e01f`, `packetization-mode=1`) | The server takes the **first** codec in your offer that it supports. AV1 / VP9 / HEVC are skipped; if nothing else is offered you get `406 no_compatible_codecs`. | | Audio | **Opus** 48 kHz (mono or stereo) | | | Encodings | one per m-section (SSRC or a single RID) | No simulcast / SVC — offer a single encoding. RTX (`a=ssrc-group:FID`) is supported. | | Transport | BUNDLE, `rtcp-mux`, DTLS-SRTP | Offer `a=setup:actpass`; the answer is `a=setup:passive` + `a=ice-lite`. A data-channel m-section is rejected (`port 0`) but does not fail the offer. | No STUN/TURN is needed: the server sits on a public address and the client connects straight to the candidates in the answer. Outbound UDP must be allowed. ## Errors Errors are JSON (`{ "error": "...", "error_code": "..." }`) and also carry the code in an `X-WHIP-ERROR` header, which is exposed to cross-origin callers. | Status | `X-WHIP-ERROR` | Meaning | |---|---|---| | 400 | `missing_sdp` / `invalid_sdp` / `invalid_rtp_encoding` / `invalid_stream_id` | Body is not a usable SDP offer (e.g. a video section with no SSRC or RID). | | 401 | `invalid_stream_key` / `bearer_mismatch` / `invalid_key` / `invalid_token` / `authentication_required` / `user_not_found` | Key or token not recognised, or path key ≠ bearer key. | | 403 | `user_banned` / `not_your_stream` | | | 404 | `slot_not_found` / `stream_not_found` / `session_not_found` | The last one is a `PATCH`/`DELETE` to a resource that already ended. | | 406 | `no_compatible_codecs` | Offer only VP8 / H.264 / Opus. | | 409 | `wrong_protocol` / `stream_not_live` | Slot is set to RTMP/JSMPEG (change the streaming method), or (JWT form) session is not live. | | 502 | `transport_creation_failed` / `dtls_negotiation_failed` / `producer_creation_failed` | SFU could not set up the session — retry. | | 503 | `sfu_unavailable` / `whip_unavailable` | Server-side WebRTC is down. | ## Cross-origin (CORS) Every `/whip/*` response, including errors, carries: ``` Access-Control-Allow-Origin: * Access-Control-Allow-Methods: POST, PATCH, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization, If-Match Access-Control-Expose-Headers: Location, X-WHIP-ERROR ``` There are no cookies involved, so `credentials` is never needed — call `fetch()` normally. You can verify from a shell: ```bash curl -si -X OPTIONS https://whip.openvibe.live/whip/x \ -H 'Origin: https://my-static-site.example' \ -H 'Access-Control-Request-Method: POST' \ -H 'Access-Control-Request-Headers: content-type' # → HTTP/2 204 with the headers above ``` ## Publishing from a browser > **Direct link to this section:** (also on [GitHub](https://github.com/OpenVibers/OpenVibe.Live/blob/main/docs/whip.md#publishing-from-a-browser)) > Everything needed to build a browser-only publisher is in this section — copy it whole into your AI assistant or your notes. **Spec in five lines (paste-ready):** ```text OpenVibe.Live WHIP ingest — browser publisher requirements 1. POST the SDP offer to https://whip.openvibe.live/whip/ with Content-Type: application/sdp. No cookies, no other auth; CORS is open (*). 2. Expect 201; body = SDP answer (application/sdp); Location header = session resource URL (read it — it is exposed via CORS). 3. Offer: one audio + one video m-section, a=setup:actpass, BUNDLE + rtcp-mux, single encoding (no simulcast). Codecs: VP8 or H.264 (42e01f, packetization-mode=1) + Opus 48k. AV1/VP9/HEVC are ignored. 4. No STUN/TURN needed (server is ICE-lite); do not wait for ICE gathering; PATCH/trickle is optional. 5. Stop with DELETE (use fetch keepalive on pagehide). Errors: JSON + X-WHIP-ERROR header (see table above). ``` A complete publisher is about forty lines. This is the core of [`whip-publisher.html`](../public/whip-publisher.html) (hosted at ), which adds a UI, screen-share, bitrate stats and clean-up on tab close: ```html ``` Things worth knowing: - **Secure context.** `getUserMedia` only works on `https://`, `localhost`, or a local `file://` — plain `http://` hosting will not get camera access. - **Screen share.** Swap `getUserMedia` for `navigator.mediaDevices.getDisplayMedia({ video: true, audio: true })`. Listen for the video track's `ended` event — that is the browser's own *Stop sharing* button. - **Codec choice.** Chrome and Firefox offer VP8 first, Safari offers H.264 first; both are accepted as-is. If you use `RTCRtpTransceiver.setCodecPreferences()`, keep VP8 or H.264 at the front — the server negotiates the first supported codec it sees. - **Bitrate.** Set `maxBitrate` in `sendEncodings` (or later via `sender.setParameters()`); 2.5 Mbps for 720p30 and 4.5 Mbps for 1080p30 are good defaults. - **Reconnecting.** On `connectionState === 'failed'`, build a fresh `RTCPeerConnection` and `POST` again — the new session replaces the old one on the same slot, and viewers pick up automatically. - **Ending cleanly.** Always `DELETE` the resource (use `keepalive: true` so it survives tab close). If you don't, the stream lingers until the 15-second ICE grace period expires. - **The key is visible in the page.** That is inherent to a backend-less publisher and the same trust model as OBS; keep the page private (or have the streamer paste their own key at runtime, as the hosted demo does) rather than committing a key into a public repo. ## Other clients - **OBS Studio (30+):** *Settings → Stream → Service: WHIP*, Server = the WHIP URL, Bearer Token = your stream key. - **FFmpeg (7.0+):** `ffmpeg -re -i input.mp4 -c:v libx264 -profile:v baseline -level 3.1 -pix_fmt yuv420p -c:a libopus -f whip "https://whip.openvibe.live/whip/"` - **GStreamer:** `... ! whipsink whip-endpoint="https://whip.openvibe.live/whip/"` (needs `gst-plugins-rs`). The Broadcast page shows ready-to-paste commands for each with your key filled in. See [Broadcasting Guide](broadcasting.md) for the other ingest methods. ## Going live: WebRTC, WHIP, RTMP (OBS) and the JSMPEG/CLI path https://openvibe.live/docs/broadcasting # Broadcasting Guide OpenVibe.Live supports four live broadcast methods, each suited to different use cases. ## Broadcast Methods ### WebRTC (Browser) - **Best for**: Quick streams directly from your browser - **Latency**: Sub-second - **Setup**: Click "Go Live", grant camera/mic permissions - **Features**: Screen share, camera PiP overlay, live stats, camera switching ### RTMP (OBS/Streamlabs) - **Best for**: Professional streaming with OBS, multi-scene setups - **Latency**: 2-5 seconds - **Setup**: Copy Server URL and Stream Key into OBS - **Features**: Full OBS feature set, live thumbnail preview on broadcast page ### WHIP (OBS, FFmpeg, GStreamer — or any web page) - **Best for**: WebRTC-native encoders and custom publishers, including browser-only sites - **Latency**: Sub-second - **Setup**: Choose *Streaming method: WHIP*, copy the WHIP URL into OBS (Service: WHIP) or `ffmpeg -f whip` - **Features**: VP8/H.264 + Opus, auto-creates the live session on first POST, open CORS so a static page can publish with `RTCPeerConnection` + `fetch()` — see the [WHIP Ingest API](whip.md) / [Publishing from a browser](whip.md#publishing-from-a-browser) and the hosted [browser publisher](https://openvibe.live/whip-publisher.html) ### OpenRestream (OBS, restreaming everywhere) - **Best for**: Streaming to Twitch, YouTube, Kick and other servers at the same time as your OpenVibe Live channel - **Setup**: Create a stream on [openre.stream](https://openre.stream), copy its RTMP server and key into OBS, and add your other destinations there - **On Live by default**: each stream made on OpenRestream gets its own slot on your channel, marked *managed on OpenRestream*. Its sessions go live on `openvibe.live/@you` with chat, clips and VODs, through the OpenRestream mirror (`server/openre/mirror.js`). Switch "Show it on my OpenVibe Live channel" off on OpenRestream and Live ignores that stream; your other destinations keep going. - **How**: OpenRestream calls `POST /internal/openre/slots` (capability `live.openre.slot.bind`, [server/openre/bind.js](../server/openre/bind.js)) with a Network service token. Live creates a new slot with `ingest_authority = 'openre'`, or returns the one already bound to that stream. It never changes an existing slot. A person needs a Live account first, which they get by signing in to openvibe.live once. ### JSMPEG (FFmpeg) - **Best for**: Headless/embedded streaming (Raspberry Pi, IP cameras, 3D printers) - **Latency**: ~1 second - **Setup**: Run FFmpeg command with the provided endpoint - **Features**: Flexible input sources, low resource usage ## Broadcast Page Features ### Live Controls When streaming via WebRTC browser, you have access to: - **Stop Stream** — End the broadcast - **Mute** / **Camera off** — Silence the microphone or black out the camera without ending the stream (the track is disabled; the choice holds when the camera is switched or a track recovers) - **Flip camera** — Front ↔ back in one tap (shown when the device has more than one camera) - **Choose camera** — Pick any camera from a list - **Share screen** — Share screen/window/tab - **Camera in corner** — Picture-in-picture camera over a shared screen - **Mic** — Add or remove the microphone while sharing a screen - **Stream health** — Show bitrate, FPS, resolution, codec overlay - **Test chat voice** / **Skip spoken message** — Hear how chat is read aloud; stop the current message and clear the queue - **Hide preview** — Hide your own preview (you stay live; saves CPU and battery) - **Watch as viewer** — Open your channel the way viewers see it (real mediasoup consumer path) - **Clip** — Create a clip from the live stream - **Media requests** — Media request PiP player ### The live studio on a phone On a phone (narrower than 760 px, or a phone turned sideways), a browser stream opens the live studio (`public/js/broadcast-studio.js`): the camera fills the screen, the stream chat sits over the bottom of the picture, and the controls sit on top. The top bar has LIVE, time live, people watching, restream status and connection, with **End** (it asks first). The right-hand rail has Flip, Mute, Camera, Light (the torch, when the camera has one), Chat, Clip (the last 30 seconds) and More, a sheet with share, watch as viewer, choose camera, screen, media requests, a one-minute clip, chat read aloud, the restream controls, stream health, all settings and **Full page** (the classic page, with a button back). The studio drives the same functions as the page and reads its counters. ### Stream Chat The broadcast page includes an embedded chat sidebar with: - Full chat functionality (emotes, GIFs, TTS) - **Pop Out Chat** button — opens chat in a separate window (lightweight WebSocket popup) - User list and chat settings - Mobile: floating action button to toggle chat overlay ### Disconnect Handling - Visual disconnect alert banner when the connection drops (a browser broadcast's connection, or the encoder's RTMP feed stopping after it was received) - **Audible alert** — opt-in audio beep on disconnect (enable in Audio settings → "Disconnect Sound Alert", or Alerts → "Disconnect Audio" in the encoder settings; one preference, kept in this browser) - **Low-bitrate alert** — opt-in (Audio settings → "Low Bitrate Alert"): when a browser broadcast's upload stays under 30% of its target bitrate for about 18 seconds, a warning and a lower beep, once until it recovers - Automatic reconnection with exponential backoff (3s → 30s max) - Connection status indicator (protocol name when connected, status when not) ### RTMP Preview When using RTMP, the page shows your stream's live thumbnail (`/api/thumbnails/stream--live.jpg`), checked every 10 seconds. Live thumbnails, the ones the live cards show too, are refreshed about every 2 minutes: the server grabs a frame from RTMP, JSMPEG and WebRTC/WHIP streams, and a browser broadcaster with a visible tab posts its own. ## Multi-Stream You can run multiple simultaneous streams (e.g., different cameras). Each gets its own tab in the broadcast page with independent controls and viewer counts. ## Stream Settings Configurable per-stream settings include: - Video resolution and frame rate - Target bitrate - Audio device selection - TTS mode and voice settings - Auto-reconnect behavior - Stream title and category ## Bot and integration tokens (hbt_...) for the API https://openvibe.live/docs/api-tokens # API Tokens API tokens provide programmatic access to OpenVibe.Live for bots, integrations, and automation tools. ## Overview - Tokens use the `hbt_` prefix for easy identification - Each user can create up to **10 active tokens** - Tokens are hashed with SHA-256 before storage — the raw token is shown only once at creation - Tokens support scoped permissions and optional expiration - The dashboard exposes integration presets, including a dedicated vibe-coding publisher preset ## Creating a Token ### Dashboard UI 1. Go to your **Dashboard** 2. Find the **API Tokens** card 3. Click **Create Token** 4. Enter a label or use a preset, select scopes, and optionally set an expiration 5. **Copy the token immediately** — it will not be shown again ### REST API ```bash curl -X POST https://openvibe.live/api/auth/tokens \ -H "Authorization: Bearer YOUR_JWT" \ -H "Content-Type: application/json" \ -d '{"label": "My Bot", "scopes": ["chat", "read"]}' ``` Response: ```json { "token": "hbt_a1b2c3d4e5f6...", "id": 1, "label": "My Bot", "scopes": ["chat", "read"] } ``` ## Scopes | Scope | Access | |-------|--------| | `chat` | Send and receive chat messages via WebSocket | | `read` | Read streams, VODs, user info | | `stream` | Start/stop streams, update stream info | | `control` | Hardware control bridge access | | `vibe_coding_publish` | Publish sanitized coding-feed events to `/ws/vibe-coding/publish` | ## Recommended Presets - `Chat Bot` → `chat`, `read` - `GitHub Copilot Companion` → `read`, `vibe_coding_publish` - `Stream Controller` → `read`, `stream`, `control` ## Using a Token ### REST API Include the token in the `Authorization` header: ```bash curl https://openvibe.live/api/streams \ -H "Authorization: Bearer hbt_YOUR_TOKEN_HERE" ``` ### Vibe Coding Publisher For the OpenVibe.Live VS Code companion or any other coding-feed publisher, use a token with the `vibe_coding_publish` scope. It is the only scope `/ws/vibe-coding/publish` and the `/api/vibe-coding` writes accept; a `stream` token is refused there. ### WebSocket (Chat) Send the token in an `Authorization: Bearer` header on the upgrade request (Node's `ws`, Python's `websockets` and most server libraries can), or as the `token` of your first `join` message (browsers cannot set headers): ``` wss://openvibe.live/ws/chat?stream=123 Authorization: Bearer hbt_YOUR_TOKEN_HERE ``` ```json { "type": "join", "streamId": 123, "token": "hbt_YOUR_TOKEN_HERE" } ``` The token works everywhere a JWT would — the server auto-detects the `hbt_` prefix and validates accordingly. A token in the WebSocket URL (`?token=`) no longer signs a socket in (removed 2026-10-10): URLs end up in proxy logs and browser history. Use the header or the `join` message. ## Managing Tokens ### List tokens ```bash curl https://openvibe.live/api/auth/tokens \ -H "Authorization: Bearer YOUR_JWT" ``` ### Revoke a token ```bash curl -X DELETE https://openvibe.live/api/auth/tokens/TOKEN_ID \ -H "Authorization: Bearer YOUR_JWT" ``` **Note:** Tokens cannot create or revoke other tokens (must use JWT auth for token management). ## Bot Example A minimal Node.js chat bot: ```javascript const WebSocket = require('ws'); const TOKEN = 'hbt_your_token_here'; const STREAM_ID = '123'; const ws = new WebSocket(`wss://openvibe.live/ws/chat?stream=${STREAM_ID}`, { headers: { Authorization: `Bearer ${TOKEN}` }, }); ws.on('open', () => { ws.send(JSON.stringify({ type: 'join', streamId: Number(STREAM_ID) })); console.log('Connected to chat'); }); ws.on('message', (data) => { const msg = JSON.parse(data); if (msg.type === 'chat' && msg.message.startsWith('!hello')) { ws.send(JSON.stringify({ type: 'chat', message: `Hello ${msg.username}!` })); } }); ``` ## Security Notes - Treat API tokens like passwords — never commit them to source control - Use the narrowest scope necessary for your use case - Prefer `vibe_coding_publish` over `stream` for coding-feed publishers so tokens stay least-privilege - Set an expiration for tokens used in shared environments - Revoke tokens immediately if compromised - The `last_used_at` field in the token list helps identify unused tokens for cleanup ## Chat features and moderation https://openvibe.live/docs/chat-system # Chat System The OpenVibe.Live chat system provides real-time messaging, moderation, and extensibility. ## Architecture - **WebSocket server**: OpenVibe.Chat (nginx sends `/ws/chat` there) — Live runs no chat server of its own - **REST API**: OpenVibe.Chat (nginx sends `/api/chat`, `/api/dm`, `/api/tts`, `/api/sounds` there) — moderation endpoints, message search, admin tools. Live's local `server/chat/routes.js` is retired. - **Client**: `public/js/chat.js` — rendering, emotes, TTS, settings sync ### OpenVibe.Chat (roadmap Wave 6; live since 2026-09-23) Chat runs in [OpenVibe.Chat](https://github.com/OpenVibers/OpenVibe.Chat) (127.0.0.1:4400) with the same WebSocket protocol and REST paths. Live runs no chat server, and there is no switch (the `CHAT_AUTHORITY` flag was retired on 2026-10-11); the local `ChatServer` and its `/ws/chat` upgrade path are deleted. Without Live's service credentials (development, tests) chat is inert. - nginx sends `/ws/chat`, `/api/chat/`, `/api/dm/`, `/api/tts/` and `/api/sounds` to Chat; Live mounts none of them, and a `/ws/chat` upgrade that still lands on Live is destroyed. - Live's chat producers (AI viewer lines, relayed and RobotStreamer chat, donations and alert sounds, cards, `/api/mod` deletes and moderation, cache hints) go through one seam, [server/chat/chat-delivery.js](../server/chat/chat-delivery.js), to Chat's typed service-token ingress ([server/chat/chat-client.js](../server/chat/chat-client.js): `POST /internal/chat/messages|events|moderation|invalidate`, `GET /internal/chat/presence`). Chat persists, broadcasts and speaks (`tts`) in one call per operation; each carries one idempotency key reused on every retry. A Chat 4xx is logged and dropped; a 5xx or timeout is retried past Chat's five-minute delivery lease, then logged and dropped; neither is thrown. - `server/chat/chat-delivery.js` also carries the surface Live's remaining modules used on the old chat server: the push names (`broadcastToStream`, `broadcastToChannelRoom`, `sendDm`, …) go to the same ingress; synchronous reads (`getTotalConnections`, viewer counts, slow modes, a connected user's IP, anon ids) come from Chat's presence snapshot, polled every 3 s by `init()`; Live's IP-approval and ban writes send Chat a cache hint; a push with no ingress target (`forwardToGlobal`, `synthesizeAndBroadcastTTS`, …) is dropped and logged once, and TTS is never synthesised in Live. The arena commands (`/internal/chat-effects/arena-command`) answer the sender in the response's `replies`. - Deploy notices reach Chat only as the `live.release.deployed` event: with Events publishing off the commits stay unannounced and the next boot tries again. - **The old ordered-calls bridge is gone** (T3 J2): no `chat-remote.js`, no `POST /internal/live/calls`, no placeholder ids, and nothing under `server/` reads or writes `chat_bridge_outbox` (`test/chat-bridge-removed.test.js`). The table is dropped by the operator migration `op_002_drop_chat_bridge_outbox` through `scripts/chat-bridge-outbox-drop.js`, once the release before is out of rollback range: `--deliver` first hands Chat (its bridge receiver, until Chat's J3) every chat write that release queued and Chat never acknowledged, then `--apply` takes a backup and drops the table; the drop refuses while any `op = 'db'` row remains. - Chat reads Live data and asks for side effects on `/internal/chat-context/*` and `/internal/chat-effects/*` (`server/chat/live-context-routes.js`), with Network service tokens (`live.chat_context.read`, `live.chat_effects.write`, `server/net/service-guard.js`). Effects re-check the acting moderator/owner/admin. - Live reads chat stats, queues and history from Chat's internal read API — `POST /internal/chat/stats` (`site`, `user`, `stream`, `channel-top` and `site-daily`), `GET /internal/chat/messages`, `/timeline`, `/first-chat`, `/sounds`, `/sounds/by-command`, `/moderation/pending-ip`, `/moderation/relay-users`, `/moderation/tts-override` — through [server/chat/chat-reads.js](../server/chat/chat-reads.js), replacing the read mirror for home stats, recaps, AI context, VOD chat replay and the `/api/mod` queues. A Chat outage answers a cached value, never a 500; outside chat mode (dev, drills) the same reads answer null/empty — Live keeps no copy of Chat's tables (below). - **The read mirror is retired** (2026-10-05): OpenVibe.Chat #25 removed the sender (its `LIVE_MIRROR` writer and the capture triggers), and this change removed Live's receiver — the `POST /internal/chat-effects/mirror` route in `live-context-routes.js`, its `live.chat_mirror.write` capability and the `chat-delivery.js` mirror helper. Nothing in chat mode read it any more: the two HOME_SERIES charts `messages` and `active` read Chat's site-daily series, the AI context's "first time chatting here" flag reads Chat's first-chat read, and the RobotStreamer `!sound` lookup reads Chat's sounds-by-command read — each through [server/chat/chat-reads.js](../server/chat/chat-reads.js); an unreachable Chat answers a cached value, else null/empty, never a Live copy. - **Every chat table is Chat's** (roadmap T3). The staged set — `channel_moderators`, `channel_moderation_settings`, `emotes`, `user_tags`, `chat_ai_summaries` and `chat_timeline_events` — is read only through [server/chat/moderation-client.js](../server/chat/moderation-client.js) (Chat's internal read API, `GET /internal/moderation/...`, capability `chat.moderation.read`, cached 30 s, off under `LIVE_DRILL`); the dashboard, channel page and upload UI call Chat directly (`/api/chat/channels/:id/…`, `/api/emotes`, `/api/chat/ai/…`); alert sounds are Chat's, played on Live's request as an `alert` event `{ streamerId, streamId, kind }` on Chat's ingress ([server/monetization/alerts.js](../server/monetization/alerts.js)). Live's staged-table machinery, its chat-AI summary job and the old emote/channel-moderation routes were deleted in N+1; the seven supporting tables were dropped in N+2 and the unread `emotes` copy in N+3 (ADR-016). - **The twelve chat tables are dropped** (T3 final step, 2026-10-05): `chat_messages`, `dm_conversations`, `dm_participants`, `dm_messages`, `dm_blocks`, `tts_voice_overrides`, `channel_sounds`, `relay_users`, `hidden_relay_users`, `pending_ip_messages`, `stream_first_chats` and `moderation_actions`. Chat imported them with ids kept at the cutover and has been their only writer since; Live's readers and writers are gone (#28–#32 and the release that carries this, #33) and it no longer creates them. #33 is in production and its N-1 fixtures no longer run SQL over the tables ([test/n-1.test.js](../test/n-1.test.js)), so SQLite-era boot migration 007 dropped them (ADR-028); on PostgreSQL (since 2026-10-08) they were never created. The deployment notice is Chat's too: Live queues `live.release.deployed` and Chat folds and stores the rolling card. `test/chat-tables-dropped.test.js` guards that nothing under `server/` names them. ## Features ### Real-time Chat - Per-stream chat rooms with global fallback - Authenticated and anonymous users (anon IDs like `anon123`) - Message history on connect - User presence tracking (join/leave/count) ### Moderation Tools - Ban, timeout, unban users - Message deletion (individual or bulk time-range purge): the lines leave every surface that showed them (the stream, the rest of the channel room and its popouts, the global feed) and cursor reads name them in `deleted_ids`. `/clear` only clears screens; the lines stay in history - Slow mode: the channel's saved `slow_mode_seconds` (dashboard, or `/slow N` / `/slow off`), enforced by OpenVibe.Chat and kept across restarts; moderators are exempt - Sub-only mode (`sub_only`: dashboard, or `/subonly` / `/subonly off` by the streamer, channel mods and chat staff): only people with an active channel subscription (`subscriptions`, asked through `GET /internal/chat-context/subscriber`), the streamer, channel mods and chat staff may chat; Network VIP does not count, anonymous viewers cannot chat, and when Live cannot be asked the answer is no - Blocks: someone who blocked a person on OpenVibe.Network no longer gets that person's lines, live or in history reads (only their own view; moderation logs show everything) - Word filtering and opsec filtering ### Chat Logs & Admin - Paginated, filterable chat log viewer - Filter by username, stream ID, date range, message type - Export to CSV or JSON (up to 50,000 messages) - Bulk purge by time range with preview count ### Settings Sync - Chat settings (font size, timestamps, badges, TTS preferences) sync to server - Server is the source of truth with local cache fallback - Settings persist across devices via `GET/PUT /api/auth/preferences` ### What people wear A chat line shows what its sender wears from [OpenVibe.Inventory](https://inventory.openvibe.network). Live is the issuer of the `live.*` kinds; the Workshop's community badges are `network.badge`. - **Name effects** (`nameFX`): a CSS class on the name, such as Rainbow or Glitch (`public/css/cosmetics.css`). - **Particles** (`particleFX`): a burst around the name. - **Hats** (`hatFX`): an emoji before the name. - **Community badges** (`badgeFX`): the badge's reviewed image, from OpenVibe.Media, before the name. - **Voices** (`voiceFX`): the TTS voice preset. OpenVibe.Chat asks Live for these through `POST /internal/chat-context/decor`. Live reads Inventory 100 people at a time and keeps each answer 30 seconds (`server/monetization/inventory-client.js`), and an Inventory outage just leaves names plain. People earn items through OpenVibe.Quest's quest rewards, or are given community badges by their makers in the Workshop. Nothing is bought or sold. The same items show on openvibe.chat, Community and Network profiles, drawn by `openvibe-shared` `items.js`. ### Text-to-Speech (TTS) - Site-wide server-generated TTS audio - Self-hosted browser-voice TTS - Per-channel TTS settings (volume, pitch, rate, voice, duration limit) - TTS only activates on the broadcaster's own channel page ## Admin Endpoints ### Chat Logs ``` GET /api/chat/admin/logs?page=1&limit=50&username=bob&from=2024-01-01&to=2024-12-31 ``` ### Export Logs ``` GET /api/chat/admin/logs/export?format=csv GET /api/chat/admin/logs/export?format=json ``` ### Purge Preview ``` POST /api/chat/admin/purge/preview Body: { "streamId": 123, "from": "2024-01-01T00:00:00Z", "to": "2024-01-02T00:00:00Z" } ``` ### Execute Purge ``` DELETE /api/chat/admin/purge Body: { "streamId": 123, "from": "2024-01-01T00:00:00Z", "to": "2024-01-02T00:00:00Z" } ``` `from` and `to` (also on the log filter and `/api/chat/:streamId/replay`) are UTC, as ISO instants or SQLite's `YYYY-MM-DD HH:MM:SS`; both bounds are inclusive. The preview, the purge, the log filter and VOD chat replay all read them the same way, so a purged range disappears from replay exactly. ## WebSocket Protocol ### Connect ``` wss://openvibe.live/ws/chat?stream=123 Authorization: Bearer JWT_OR_API_TOKEN (non-browser clients) ``` Browsers send the token in the first `join` message instead (`{ "type": "join", "streamId": 123, "token": "…" }`): the socket is anonymous until then. Neither a cookie nor a `?token=` query parameter signs a socket in (both removed 2026-10-10, OpenVibe.Chat#55). ### Message Types (Client → Server) | Type | Fields | Description | |------|--------|-------------| | `chat` | `message` | Send a chat message | | `join` | `streamId` | Join a stream's chat room | ### Message Types (Server → Client) | Type | Fields | Description | |------|--------|-------------| | `chat` | `username`, `message`, `timestamp`, ... | Chat message | | `system` | `message` | System notification | | `delete` | `messageId` | Single message deleted | | `purge` | `fromTime`, `toTime` | Bulk messages purged | | `tts` | `text`, `voice` | Server-generated TTS event | | `tts-audio` | `url` | TTS audio file URL | | `user_count` | `count` | User presence update | ### TTS playback hardening (2026-08-31) `media-src` now includes `data:` (the mod voice preview plays a `data:audio/…` URL; without it the element dies with "no supported source"). The admin **Test Voice** (`POST /api/tts/admin/test`) and mod preview (`POST /api/mod/tts-voice/preview`) additionally return a same-origin `url` — the clip is parked in the shared TTS cache (`data/tts-cache`) and streamed from `GET /api/tts/audio/.` (strict filename, no traversal) — so playback works even where a browser or shield refuses `blob:`/`data:` audio. Chat/broadcast TTS players retry once with a `data:` URL when the element rejects the blob URL. Note: `tts-audio` WS payloads carry base64 (`audio` + `mimeType`), not a `url`. ## Auto-translation (non-English streamers) Every channel has a **language** it lives in: the streamer's explicit `channels.chat_language` (`PUT /api/streams/channel` with `chat_language: 'ja'` / `'auto'` …), or, when `auto`, the language detected from any non-English line in their bio or name (`server/i18n/translate.js`). `GET /api/streams/channel/:username` returns it as `language: { code, name, flag, explicit, translate }`, and the chat `auth` frame carries `channel_language` so the composer can show the "Auto-translated ↔ Japanese" pill. When AI is enabled (`ai_enabled`, and `AI_SERVICE` not `off`; the `chat_translate_enabled` site setting defaults to on), each chat line is translated a beat after it is broadcast and delivered to the same rooms as ```json { "type": "chat_translation", "id": 123, "from": "ja", "to": "en", "text": "…" } ``` Direction rules: any non-English message → English; an English message in a non-English channel → the channel's language (so a Japanese streamer reads his chat without anyone typing Japanese). Emote-only / command / link-only lines are skipped. Translations are cached by content hash in the `translations` table and persisted by Chat into the message's `metadata.translation`, so history renders them too. Calls go through `ai/llm.js` (metered, budgeted, max 3 in flight; overflow lines simply stay untranslated). **Any line, your language.** Every chat message also has a hover 🌐 button that asks `POST /api/i18n/translate { text, to }` for the line in the viewer's browser language (rate-limited per IP: 15/min anonymous, 40/min signed in; cached like the automatic translations). This is the third direction — a Korean viewer reading a Japanese streamer, a Japanese viewer on an English stream — so nobody is left out, English users included. Related surfaces: `GET /api/streams/channel/:username/bio-en` (bio in English), `GET /api/chat-ai/live-captions/:username` (English rendering of live speech — needs the AI timeline on and a multilingual whisper model, see `WHISPER_MODEL_MULTI`), and the home-page "Star of OpenVibe" spotlight (`GET /api/home/star`, `star_streamer` setting / `STAR_STREAMER` env). ## Friendly global chat (2026-09-17) A viewer-side setting (chat settings → Behavior). When on, messages in global chat that match `FRIENDLY_FILTER_CATEGORIES` in `server/chat/moderation-utils.js` (slurs, hate slogans, threats, sexual content, anything sexualising minors) fold into a "N messages hidden by Friendly chat · Show" line. Nothing is deleted and nobody is moderated for it. It is on for a viewer's first day (the chat `auth` message carries `newcomer`, from the account age or the anon's first sighting) unless the viewer chose otherwise; the rules come from `GET /api/chat/filters/friendly`. ## History and rendering `buildChatMessageEl()` in `public/js/chat.js` renders every surface (page, popout, broadcast desk, floating widget). History renders off-screen: the newest 50 rows are swapped in at once, then the older scrollback (up to 500 channel + 500 global rows) is put above them 10 rows at a time, as many pieces as fit in each idle moment (`_historyPieces`), keeping the reader where they are. All of it in one task was a 2.7 s freeze on a phone-speed CPU (2026-10-17). The last 200 rows of a room are cached in localStorage per user for an instant paint on reload (its newest 50 render). The view follows new messages unless the reader scrolled back (`_watchChatPin`). ## How VODs and clips are recorded and cut https://openvibe.live/docs/vods-and-clips # VODs & Clips ## Finished media analysis `LIVE_MEDIA_ANALYSIS=local` is the default: Live's existing frame and transcript jobs produce VOD and clip overviews and timed transcripts. Set `LIVE_MEDIA_ANALYSIS=ai` to send each finished VOD or clip's signed Media URL to OpenVibe.AI's `media.analyze` workflow (AI runs one analysis at a time at nice 15). Live fills a missing overview (an overview it already has is kept), derives the short overview, and stores timed speech segments in `vod_ai_state` or `clip_ai_state`. Live waits up to a minute for the run (`LIVE_MEDIA_ANALYSIS_WAIT_MS`, 60000); a longer analysis is re-joined 15 minutes later by its idempotency key (`live.media.analyze:::`), so the same recording is never analysed twice at once and no loop waits for it. AI's signals, scenes, and highlights have no Live state columns and are not stored. Failed or unavailable analyses retry with backoff; Live does not run its VOD/clip decoder in this mode. Live stream captions and sound events continue using their real-time paths. The `live_media_analysis_retry_total` metric counts deferred analyses by media kind. > Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media; > Live proxies to it (`server/media-proxy/`) and starts/stops recordings (`server/streaming/recorder.js`). > Pastes live in OpenVibe.Community. Live's old local `vods`/`clips`/`pastes` tables are frozen: nothing > writes or reads them ([below](#live-reads-nothing-from-the-frozen-tables)); the PostgreSQL > schema has no copies of them. Implementation details below may be out of date. ## Recording System OpenVibe.Live automatically records your streams as VODs (Video on Demand). ### Protocol-Specific Recording | Protocol | Recording Method | Format | |----------|-----------------|--------| | **WebRTC (Browser)** | Browser-side MediaRecorder, chunks uploaded to server | WebM (VP8/VP9 + Opus) | | **RTMP** | Server-side FFmpeg capture | WebM (VP8 + Vorbis) | | **JSMPEG** | Server-side FFmpeg via WebSocket relay | WebM (VP8 + Vorbis) | ### Recording Lifecycle 1. **Start**: Recording begins when the stream goes live 2. **Live DVR**: A seekable sidecar file is generated periodically (every 60s for server-side, every 2 chunks for browser uploads) so viewers can rewind 3. **Finalize**: When the stream ends, the recording is remuxed for proper seeking, duration is probed, and a thumbnail is generated 4. **Auto-cleanup**: Recordings shorter than 10 seconds are automatically deleted (test streams, accidental go-lives) ### Browser Tab Close Safety If you close the browser tab during a WebRTC stream, `sendBeacon` attempts to upload any remaining chunks. The server will auto-finalize when it detects the stream has ended. ## DVR / Live Seeking Viewers can seek backwards in a live stream using the DVR controls: - **Click/drag** the progress bar to seek - **Arrow Left** — Rewind 5 seconds - **Arrow Right** — Forward 5 seconds - **LIVE button** — Jump back to the live edge DVR availability appears after ~30 seconds of recording. ## Clips Viewers can create clips from live streams: 1. Click the **Clip** button during a stream 2. Set the clip duration (default: 30 seconds) 3. The clip is saved from the server-side recording Clips are **unlisted by default** — the stream owner can publish or delete them from the dashboard. ## VOD Management From the dashboard: - Toggle VODs between **public** and **private** - **Bulk delete** old media by age (e.g., delete VODs older than 30 days) - Every delete (one, bulk, by age, or from the admin storage page) ends in `server/media-proxy/purge.js` once Media confirmed it: the comment thread is hidden, Live's own rows about the item go (`vod_ai_state`/`clip_ai_state`, `content_views`) and its Search document is re-read at once - **Thumbnails** are auto-generated; broken thumbnails auto-regenerate on load ## Recordings and clips are watched on OpenVibe.Video Every recording and clip has its page on OpenVibe.Video (`https://openvibe.video/v/`, `/c/`; `server/web/video-url.js`, `VIDEO_PUBLIC_URL` overrides the origin): the player, AI chapters and captions, the chat replay, comments, clipping, and the owner's visibility and delete, for private items too (to their owner). Live's old `/vod/` and `/clip/` URLs answer a 301 there (`server/web/video-handover.js`, mounted before the SEO renderer like the paste handover; a `?t=` start time goes along as seconds). Every watch link Live renders points there: the SPA through `videoHref()` (the shell's `ov-video-base` meta names the origin), the server through `videoUrl()`. Live's sitemap leaves recordings and clips out (Video's lists them), and the documents Live sends to OpenVibe.Search link there. `/vods` and `/clips` stay on Live as the Content feed with a filter, linking out. Live keeps the recording pipeline, the API (`/api/vods`, `/api/clips`) that OpenVibe.Video reads, and the owner's manager on the dashboard. ## Playback URLs Every VOD and clip row Live answers (`/api/vods`, `/api/vods/:id`, `/api/clips`, `/api/clips/:id` and the per-user lists) carries three URLs, set by `setPlaybackUrls()` in `server/media-client.js`: - `playback_url`: Media's HLS master playlist (`…/o//master.m3u8`) when the object has a timeline, otherwise the byte route (`openvibe.media/v/`, `/c/`) for old and MP4-only objects. - `hls_url`: the playlist alone, absent until Media has one. A private object's playlist is signed for `MEDIA_HLS_PLAYLIST_TTL_S` (Media's setting, 6 h by default): it is read from Media for each response and never stored or cached by Live (a private item's detail answer is `private, no-store`; a public object's playlist is unsigned). - `file_url`: always the byte route, for anything that needs a real file (downloads, ffmpeg, previews). `GET /api/vods/:id/file` and `/api/clips/:id/file` redirect to it. The watch pages play `hls_url` when there is one and fall back to the file. The server-rendered pages name no playback URL at all: their schema.org `contentUrl` is the page and their `og:video` tags carry only the duration. ## Views A view is one `content_views` row per item and address: Live's detail read (`GET /api/vods/:id`, `GET /api/clips/:id`) adds it. openvibe.video reads those over loopback, so its watch page counts its viewers with a beacon instead: `POST /api/views/vod/:id` or `/api/views/clip/:id` (`server/media-proxy/view-beacon.js`), sent from the viewer's browser when playback starts. It answers only `https://openvibe.video`'s origin, reads no cookie, counts only a public or unlisted finished item (Live's visibility rule, remembered for a minute), is limited to 60 a minute per address, and answers 204 whether or not it counted. ## Who sees a private VOD or clip A private item (visibility `private`, or a legacy row with no visibility and `is_public = 0`) is visible only to its owners — the uploader or clipper, the clipped channel's streamer, the streamer whose stream it came from — and to staff (admin, global mod). Everyone else gets exactly the answer an unknown id gets: the same 404 and body on `/api/vods/:id` and its `live-info`/`memories`/`context`, `/api/clips/:id`, comments (including a comment's replies, edit and delete), thumbnail regeneration and the write routes (a 403 would confirm the id exists). It is also left out of `/api/chat-ai/vod-transcripts`, the server-rendered `/vod/:id` and `/clip/:id` pages, and `/api/streams/recent`. The rule lives in `server/media-proxy/access.js`; Media applies the same rule to `openvibe.media/v/:id` and `/c/:id`. Unlisted items stay reachable by direct link. ## Comments are OpenVibe.Community threads Comments on a VOD or clip live in OpenVibe.Community, not in Live (roadmap Wave 5: Live and a second product share one Community thread). The thread of a VOD is the one Community keeps for the EntityRef `{ service: 'live', type: 'vod', id: '' }` (`type: 'clip'` for clips). `/api/comments` keeps its paths and response shapes; `server/media-proxy/comments.js` is an adapter over `server/comments-client.js`: - **Who sees them.** Live checks the VOD/clip with `access.js` first (a private item's comments are missing like the item), then resolves the thread with its service token (`community.comment.write`, audience `openvibe.community`) and remembers the thread id in `comment_thread_refs`. Community refuses browsers that try to open live/vod or live/clip threads themselves, so a private item's thread is reachable only through Live. - **Who writes.** Signed-in people only, as before (Community refuses anonymous comments on these threads too). Live sends the person's Network subject (`X-OV-Subject`, from `linked_accounts.subject_id`); an account without one gets 409 "sign in again". Community's own limits apply (10 s between comments, 5 a minute, no repeats) and come back as the error message. - **Edit** — the author only. **Delete** — the author (as themselves), staff (admin/global mod, as themselves with `X-OV-Staff: 1`, which needs `community.comment.moderate`), or the item's owners (uploader or clipper, the clipped channel, the streamer whose stream it came from — Live deletes for them as itself with `community.comment.moderate`). A top-level comment with replies stays as a tombstone (`deleted: true`). - **Deleting** a VOD or clip (the player, the dashboard bulk actions, the admin storage page) hides its thread. - **Moderation in Community** shows here: a thread Community's moderators hid answers `404 { code: "comments_hidden" }` on Live too, and a locked one refuses new comments with Community's message. - **Community down** — every `/api/comments` route answers `503 { error: "Comments are unavailable right now", code: "comments_unavailable" }` and the player says so; it never shows an empty list. `comment_count` on `/api/vods/:id` and `/api/clips/:id` is `null` then (short timeout, skipped for 30 s after a failure). - **The same thread elsewhere.** For public and unlisted items the list carries `thread: { id, url }` — the thread's page on OpenVibe.Community (`https://openvibe.community/c/`), linked under the comments as "View this thread on OpenVibe.Community". Comments made there show on Live and the other way round. Live's old `comments` table is read-only (`test/frozen-tables.test.js` fails if server code writes it). Its rows were moved with OpenVibe.Community's `scripts/import-live-comments.js`: dry run by default, `--apply` only with `--backup` of Community's database, a ledger so re-runs import nothing twice, authors mapped to Network subjects (unmapped ones held and listed), and a reconciliation `read = imported + held + excluded`. The exact production commands are in OpenVibe.Community's README ("Live's VOD and clip comments"). ## Chat Replay VODs include synchronized chat replay. Messages are stored in the database with timestamps relative to the stream start. Deleted messages are automatically excluded from replay (soft-delete with `is_deleted` flag). ## AI moments: pastes and clips never overlap Two jobs turn stream moments into content: `server/ai/ai-moments-job.js` (image **pastes** for the home hero + pastes tab, every 6 h) and `server/ai/auto-clip-job.js` (live chat-spike **clips** + a VOD backfill). They used to pick the same second of the same VOD with the same title. Now: - **One shared registry** — `server/ai/moment-registry.js` (`ai_used_moments` state) records every paste/clip (`vod_id`, `stream_id`, `offset`, scene signature, title). Both jobs ask it before creating anything: a moment is refused if it is within **2 min** of a used moment on the same VOD/stream, or if its scene signature (first five words of the description) was used in the last two weeks. Legacy logs are imported once. - **Flavoured picks** — `findBestMoment(vod, { flavor, avoid })`: `paste` asks for a frame that is striking on its own (a face, a gag, something odd on screen); `clip` asks for a beat that plays out over 25 s (a line, a reaction, a sound, chat exploding). The prompt lists the already-used timestamps and the model is told to stay away from them; if it still lands next to one, the objective signals (viewer clips, chat spikes, richest scene notes) pick a free spot. - The paste job no longer clips the paste's own moment; it asks for a **second, different beat** from the same VOD for the clip. The clip backfill avoids every offset a paste used. - **Live pastes** — the stream-memory vision call now also answers "is this frame screenshot-worthy?" with a caption (no extra call). When it says yes, `stream-memory-job` posts the frame as an image paste right away (≤ 1 per stream per 90 min, dark-frame and registry checks, setting `ai_live_pastes_enabled`, default on). These pastes carry `metadata.live = true` and no VOD link. - **Fewer tokens** — VOD showcase scores are cached for 7 days (`home_hero_moments.rankCache`); a run only scores VODs it has never scored. Every `llm.complete` call keeps its `kind` so `ai_usage` shows exactly where the budget goes (`moment_vod_rank`, `moment_pick`, `moment_frame`, `stream_memory`, `auto_clip_confirm`). ## AI-inferred category Go-live used to default every stream to `irl`, so every AI prompt (AI viewers, streamer overviews, VOD overviews, the Arena, sound detection) assumed everyone is an IRL streamer. Now the category selector defaults to **Auto** (stored as `NULL`), and the stream-memory rollup — the same summarise call that already runs — returns `{ overview, category, tags }` with `category` from the fixed taxonomy (`outdoors, travel, building, music, gaming, robot, desktop, irl, other`). It is stored on `streams.ai_category` / `streams.ai_tags` and the channel inherits the latest read (`channels.ai_category`). Everything reads the effective value: listing queries return `COALESCE(ai_category, category) AS category` (the self-selected value is still there as `chosen_category`), `stream_category` on VODs/clips, the AI viewers' session block ("judged from the stream itself"), streamer overviews, sound detection and the Arena all prefer `ai_category`. A streamer can still pick a category manually; the AI read wins once it exists. ## Live reads nothing from the frozen tables Live's local `vods`, `clips`, `pastes`, `paste_likes` and `paste_comments` tables stopped at the Media split: nothing recorded, clipped or pasted since then was ever in them, and OpenVibe.Media (VODs and clips, same ids) and OpenVibe.Community (pastes, by slug) hold the old rows too. Since register item C-73 step 2, no Live code reads them. `test/frozen-tables.test.js` fails on any write or read of these tables in `server/` or `scripts/` (SQL text, split over lines too, and `table: '…'` registries). Its allow-list of kept reads is empty. A read that has to stay goes into that list with a reason, and into this page. What used to read them, and what answers now (most of it in `server/media-proxy/lookups.js`): | Page or job | Now asks | Hidden items | |---|---|---| | Recently ended streams (`/api/streams/recent`): VOD id, thumbnail, duration | Media, per stream (cached 5 min) | never: public VODs only | | Recently online cards: slot thumbnail | Media `GET /vods/latest-thumbs` (the route already did) | never | | Channel page badges: pastes, clips taken | Community (pastes), Media (clips, `hide_self`) | owner and staff only | | Channel offline screen: top VOD and clip per week, month, all time | Media, newest 100 plus most-viewed | never | | Workspace session history: each session's VOD | Media by slot (`managed_stream_id`) | owner only (private and recording included) | | Setup hub: "Publish a paste" | Community, as the person | the person's own | | Settings: avatar history | Community, as the person (screenshots tagged `metadata.kind = 'avatar'`) | the person's own | | AI timeline (channel AI tab): each session's VOD link | Media by user, public only; not cached while Media is down | never | | Transcript view: the stream's VOD | Media (the old local fallback is gone) | never | | Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` | counts only | | Home "over time" charts for VODs, clips, hours | Media `/stats/series/*` (the route already did) | counts only | | Admin dashboard: VOD totals | Media list totals (null when Media is down) | staff | | Admin AI explorer and the streamer overview job: VODs, clips, pastes | Media and Community | staff: any visibility (the overview job reads public VODs) | | AI moments and auto-clip jobs: VOD pool, VOD source, where viewers clipped | Media (no local fallback: Media down means no run) | internal signal only | | Stream analytics: clips per stream | Media, written into `stream_analytics.clips_created` right after the stream ends | internal | | Arena portraits: VOD thumbnails | Media, public VODs | never | Removed from `server/db/database.js`: 45 exported helpers that had no caller (VOD health, public VOD and clip lists, the old paste API, paste comments and likes, filtered channel lists), the 11 whose callers now ask Media or Community, and the boot-time migrations that still rewrote rows of the frozen `vods`/`clips` tables (short overviews, transcript status) along with the indexes that only served the removed listings. The streamer overview job now only considers streamers with stream memories; the frozen `vods` table was its other signal, and its rows moved to Media at the split. ### API additions that would help (not blocking) - **Media: `GET /vods?stream_ids=1,2,3`** (a batch filter). `/api/streams/recent` asks once per stream now, up to 100 calls on a cold cache. - **Media: `created_after` on `GET /vods` and `GET /clips`.** The offline screen's week and month windows are cut from the newest 100 items; a streamer with more than 100 in a month loses that month's oldest. - **Media: `order=peak_viewers` for real.** It is an alias of `views` in Media. Live has `streams.peak_viewers` but cannot sort Media's list by it. - **Community: list by owner subject** (`GET /api/pastes?owner=usr_…`). Live lists a person's pastes by username, which relies on Community's projection cache knowing that username. - **Community: a metadata filter** (`GET /api/pastes?metadata.kind=avatar`). Avatar history lists up to 200 of the person's screenshots and keeps the avatar-tagged ones. - **Community: a public paste count.** The hero reads Community's staff-only `/api/pastes/admin/stats` with Live's `community.paste.moderate` grant. Media's `/stats` and `/stats/series/pastes` still count Media's frozen paste copies; the hero's paste chart does too. ## System design and how Live fits the OpenVibe network https://openvibe.live/docs/architecture # System Architecture ## Overview OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe services: - **Express.js** (CommonJS) — HTTP API, HTML documents, static assets - **PostgreSQL** (`openvibe-sdk/db`, through PgBouncer; PGlite in development and tests) — users, channels, streams, tokens, AI state, request analytics (`ov_live`, since 2026-10-08) - **WebSocket** (`ws`) — broadcast signaling (chat and calls are OpenVibe.Chat's; robots are OpenVibe.Bot's) - **mediasoup** — WebRTC SFU; **werift** — WHIP ingest; **Node-Media-Server** — RTMP ingest - **FFmpeg** — recording hand-off, restreams, thumbnails, audio capture for transcription - **OpenVibe.Network** — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (`openvibe-shared`, a pinned OpenVibe.Shared release served at `/shared/*` from `node_modules`) - **OpenVibe.Media** — VODs, clips, pastes, thumbnails and files. Live proxies to it (`server/media-client.js`, `server/media-proxy/`). The old local `vods`/`clips`/`pastes` tables were left out of Live's PostgreSQL database at the 2026-10-08 move, and no code may name them ([vods-and-clips.md](vods-and-clips.md#live-reads-nothing-from-the-frozen-tables)). - **OpenVibe.Community** — pastes (`server/pastes-client.js`) and VOD/clip comments: `/api/comments` is an adapter over Community comment threads (`server/comments-client.js`, [vods-and-clips.md](vods-and-clips.md#comments-are-openvibecommunity-threads)); the old local `comments` table is not in Live's PostgreSQL database. ## Authentication - Network-issued RS256 JWTs, verified with the Network public key (`server/auth/auth.js`). - API tokens (`hbt_…`) stored as SHA-256 hashes; **scopes are enforced** — reads need any scope, writes need the area's scope, and money/staff/credential routes refuse tokens. - WebSockets start anonymous or authenticated; `join` can upgrade an anonymous connection, and a change of identity rebuilds the socket (`openvibe-auth-changed` in `public/js/chat.js`). - The OpenVibe account is the identity. `network.user.updated` at `POST /internal/network-events` keeps `subject_projection` and the linked account's username, display name, picture, colour and role (`server/auth/subject-projection.js`; `linked_accounts` maps a Live user to its subject). `users.role` is that projection's output (Network's staff roles, plus Live's own `streamer`) and is read locally. The key-only `POST /internal/user-role` push is gone. - Live keeps no emails or passwords: nothing writes or reads `users.email` or `users.password_hash` (`test/identity-columns.test.js`). The contract step cleared the legacy values before the switch to PostgreSQL: no row holds an email, and `password_hash` holds only the SSO and anonymous markers (checked on `ov_live` on 2026-10-08); its SQLite script went with the SQLite code. The columns themselves stay. ## Frontend loading The SPA is plain JavaScript with no build step. What loads is decided per route: ``` public/index.html shell: navbar, home page, empty
shells, core scripts public/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetch routes: URL pattern → features public/fragments/*.html page markup for channel, dashboard, broadcast, chat, documentation, content, moments (VOD and clip pages are OpenVibe.Video's) public/css/features/*.css rules used only by one feature (split out of style.css / broadcast.css / i18n-star.css) public/js/ov-loader.js configures /shared/web-runtime.js (openvibe-shared): ov.load(feature), ov.route(path), ov.prefetch(), route generations, scopes, stubs, transactional asset groups, ov.diagnostics()/ov.leaks() public/js/app.js core: router, auth, API helper, shared renderers, connection pill public/js/app-*.js route code split out of app.js: home, channel, media, chatpage, docs ``` - **First paint.** `server/web/assets.js` renders `index.html` for the requested path: the route's stylesheets go in `` (right after `style.css`), its scripts after the core scripts, and its fragment is inlined into the section shell. A direct visit needs no extra round trip. - **Navigation.** `routeFromURL()` calls `whenRouteReady(page, render)`: the loader inserts the feature's markup first (modules bind to it on load), then dependencies, stylesheets and scripts in order, runs the feature's `after` hook once, and renders only if the route generation is still current (a slow bundle can no longer start the broadcast desk on the page you moved to). - **Unknown pages.** Every page path gets the same shell, but a path that names nothing answers **404** (server/web/page-status.js, the SPA fallback): an unknown route, a channel whose user does not exist, a VOD or clip Media does not have or that this visitor may not see (private items, by the rule in server/media-proxy/access.js), an unknown paste, recap or stream. The client renders its not-found view (`showNotFound()`) at the same URL. The server's page list mirrors `routeFromURL()`: add a new top-level route to both (test/page-status.test.js compares them). Media and paste lookups are cached per id for a minute and wait at most 2.5 s; when Media or Community cannot answer, the page is 200, never a false 404. - **Stubs.** Inline handlers such as `onclick="openSetupHub()"` work before a feature is loaded: the stub loads the feature, then calls the real function. - **Prefetch.** Hover or keyboard focus on an in-site link prefetches that route's files (``, download only); a route's `idle` list is prefetched after load. Skipped on Save-Data, 2G and low-memory devices. - **Lifecycle.** `teardownRoute()` starts a new generation and disposes `ov.scope()` (timers, listeners, observers, AbortSignal), and stops the players, sockets and polls each page owns. `test/browser/smoke.js` navigates a nine-route lap three times and fails if timers, sockets, window/document listeners or DOM nodes keep growing. - **Page events.** `showPage()` dispatches `ov:page`; the loader dispatches `ov:fragment` when markup arrives. Modules listen to these instead of observing every section. - **Assets.** Every asset URL is content-hashed at serve time (see [deploy.md](deploy.md#assets-and-caching)). - **Cached-first rendering.** `apiSWR()` (public/js/app-home.js) paints the home page's public data from `localStorage` (versioned records, keyed by user id) and reconciles with the fresh response. - **Featured live stream.** `public/js/home-featured.js` (feature `featured`) loads only when someone is live: `GET /api/home/featured` picks and rotates the stream; playback is FLV (RTMP) or the JSMPEG relay, live frames for WebRTC. Off switch remembered in `localStorage` (`ov_home_featured_off`). - **Content and Moments.** `/content` is what people made (VODs, clips people cut, people's pastes); `/moments` is what the AI made (auto-clips, AI moment pastes, AI-written after-show recaps), every card marked AI. `/vods`, `/clips` and `/pastes` are `/content` with that filter (the address becomes `/content?type=…`). One lazy feature (`public/js/content-feed.js`, fragments `content`/`moments`): a masonry of cards (one column on phones, two or three wider), infinite scroll by cursor, clips that play muted one at a time when mostly in view. The server merges the sources (`server/content/feed.js`, `GET /api/content/feed` and `/api/content/moments`): who made an item is the store's answer, never a guess here (Media's clip `auto_generated`, Community's paste `origin`, `stream_recaps.ai`), each source filters server-side, public items only. The cursor carries each source's offset (and a Top window's start); source pages are cached 30 s (Top 2 min) and a page waits at most 4 s for Media and Community, leaving a slow one out and asking again on the next page. Auto-clips made before Media kept the flag are marked from Live's `auto_clip_log` (`syncAutoClipFlags` in `server/ai/auto-clip-job.js`). - **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh WebRTC, signalling over `/ws/call`. Calls are OpenVibe.Chat's (its `server/calls/`): nginx sends `/ws/call`, `/api/streams/voice-channels…` and `/api/streams/:id/call` to Chat (the include in `deploy/nginx/openvibe.live.conf`), and Live's own call server was deleted on 2026-10-10. The newcomer offers, existing members answer; Chat pushes the channel list to every chat socket (`voice-channels` message); TURN credentials come from `server/net/turn.js` (short-lived with `TURN_AUTH_SECRET`). Live tells Chat about a stream's call through `server/streaming/calls-authority.js`: go-live, stream end, WHIP teardown and the admin force-end create or remove the stream's voice channel (POST/DELETE `/internal/calls/stream-channel`; OpenVibe.Chat `docs/calls-cutover.md`). - **Budgets.** `scripts/perf/check-budgets.js` (in `npm test`) fails if the home page's HTML, eager JavaScript or blocking CSS grow past recorded limits, or if route code returns to the home page. ## Streaming Protocols ### WebRTC (browser broadcast) ``` Browser → mediasoup Router → Consumers → Viewers (viewer sockets only for live streams) ``` ### WHIP ``` OBS / browser → POST /whip/:slot (slot key) → werift → mediasoup → Viewers ``` ### RTMP ``` OBS → Node-Media-Server :1935 → HTTP-FLV (127.0.0.1 only) → Viewers via /api/streams/rtmp-proxy ``` ### JSMPEG ``` FFmpeg → WebSocket relay → Canvas viewers ``` Recording, VODs and clips are produced by OpenVibe.Media from the live session (`server/streaming/recorder.js` starts and stops Media recordings). ## Chat ``` Client WS → OpenVibe.Chat (127.0.0.1:4400; nginx routes /ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds there) ``` Live runs no chat WebSocket server or chat REST routes: it delivers to Chat's typed ingress through `server/chat/chat-delivery.js` (producer pushes, moderation, cache hints) and reads Chat's snapshot for presence. The room is derived from the stream the client joined; offline channel chat applies the channel's bans and chat rules. Reconnects are jittered; Chat's `server_restart` notice makes the next reconnect quick and, if the socket then drops, shows "OpenVibe is updating" until it is back (history refills on reconnect). Live sends no such notice: its restarts do not touch chat sockets. See [chat-system.md](chat-system.md). ## Server runtime | Module | Path | Purpose | |--------|------|---------| | Entry | `server/index.js` | Express, security headers + report-only CSP, static mounts, WS upgrade, boot, shutdown | | Assets | `server/web/assets.js` | content hashes, HTML rewriting, route assets, fragments, previous-release serving | | Serializers | `server/web/serializers.js` | public shapes for streams, slots, channels, profiles (secrets off by default) | | Egress | `server/net/egress.js` | fetching user-chosen URLs: address policy, redirects, loopback proxy for yt-dlp/ffmpeg | | Jobs | `server/utils/jobs.js` | single-flight, jittered background loops; stopped on shutdown; stats for diagnostics | | Limits | `server/utils/limit.js` | semaphores for CPU-heavy work (offline encodes, stream-memory captures) | | Diagnostics | `server/diagnostics.js` | event-loop delay, memory, sockets, jobs, queues, migrations → `GET /api/admin/diagnostics` | | Log redaction | `server/utils/redact.js` | stream keys and credentials in log lines | | Database | `server/db/database.js` | every query, async (`openvibe-sdk/db`) | | Migrations | `migrations/NNNN_*.sql` | the schema, applied at boot by the owner role (`openvibe-sdk/db` migrate: ledger, advisory lock, expand/migrate/contract phases) | | Chat delivery | `server/chat/chat-delivery.js` | Live's one seam to OpenVibe.Chat (pushes, moderation, cache hints, presence) | | Streams | `server/streaming/routes.js` | stream and slot CRUD, channel pages | | Media proxy | `server/media-proxy/*.js` | VODs, clips, pastes, thumbnails via OpenVibe.Media | | Auth | `server/auth/auth.js`, `server/auth/permissions.js` | JWT/API tokens, scopes, role ranks | | Analytics | `server/analytics/store.js` | request analytics in the `analytics_*` tables within ADR-021 (see [Analytics](#analytics-adr-021)) | ### Migrations The schema is `migrations/NNNN_*.sql`, applied at boot on the owner's direct connection (`DATABASE_DIRECT_URL`) before the service serves on the pooled runtime role (`DATABASE_URL`, PgBouncer in transaction mode). Each file names its phase (`expand`, `migrate`, `contract`); a contract migration names the expand it completes and waits out the N-1 window. A file that has run is never edited: the next change is a new file. `0002_live.sql` was generated from production's SQLite schema at the switch to PostgreSQL (2026-10-08) and carries the compatibility functions the queries use (`datetime()`, `julianday()`, `ov_now()`). ### Analytics (ADR-021) `openvibe-shared/analytics` (the one ADR-021 module for Live, Tools and Network, wired in `server/index.js`) records one row per finished request in the `analytics_*` tables of Live's PostgreSQL database and rolls rows up hourly and daily for the Network admin dashboards. What a raw row may carry is bound by ADR-021 (OpenVibe.Contracts `docs/adr/ADR-021-analytics.md`): - **Stored:** event type, service, **route template** (the matched Express route, else `privacy.normalisePath`: no query string, ids/hashes/ULIDs → `:id`, the segment after words like `vod`, `p`, `u`, `users` → `:param`, `/@name` → `/@:user`), method, status, response time, a **rotating session id**, country (CDN header), **user-agent class** (`chrome/windows/desktop`, `bot:googlebot`) plus browser/os/device, referer **origin** only, bot flags, a signed-in flag, timestamp. - **Never stored:** IP address, user or subject id, city/precise location, the user-agent string, full referer URLs. The `ip`, `user_id`, `city` columns remain for compatibility and are always NULL. - **Session id:** 16 random hex chars kept in memory against the visitor hash; new after 30 minutes idle and at every UTC midnight. Not derived from any id. - **Bot rate check:** per-IP hit counters for the current and previous minute, in memory only (dropped after 2 idle minutes). `analytics_rate_tracking` is emptied at boot and no longer written. - **Unique visitors:** `HMAC-SHA256(day salt, ip + "\n" + user agent)`, truncated to 16 hex chars. The salt is random per UTC day (`analytics_day_salts`; deleted once the day is over). Hashes live only in `analytics_visitor_days`, never in raw events, and are deleted by the first hourly aggregation after their day ends — right after that day's final rollup — so none outlives its day by more than about an hour. Rollups keep counts only, and a recompute never lowers a stored unique count. Raw-event dashboards (sub-48 h summaries, realtime, top pages) count distinct sessions instead of IPs; "new vs returning visitors" and daily `new_users` are no longer measured (NULL). - **Retention:** raw events older than 30 days are deleted nightly in batches (job `analytics-prune`, `pruneRawEventsPg` in `server/analytics/store.js`); rollups are kept. - **Opt-out:** a request with `Sec-GPC: 1` or `DNT: 1` is not recorded at all: no raw row, visitor hash, session id or rate counter, so it is also missing from the rollups. ## OpenVibe Integration SSO and wallet come from OpenVibe.Network; media storage and processing from OpenVibe.Media. Binding inter-service contracts are in `../CONTRACTS.md` (OpenVibers workspace). Deployment: [deploy.md](deploy.md). Security posture: [../SECURITY_AUDIT.md](../SECURITY_AUDIT.md). Measurements: [performance-audit.md](performance-audit.md). ### Durable events (OpenVibe.Events) Live publishes through one transactional outbox (`event_outbox`, [server/events/stream-events.js](../server/events/stream-events.js); off unless `EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set). Every event is queued in the same PostgreSQL transaction as the change it describes. | event | when | subject | |---|---|---| | `live.stream.started` / `live.stream.ended` | a `streams` row goes live / ends | `stream ` | | `live.release.deployed` | the first boot that runs new commits ([server/events/release-events.js](../server/events/release-events.js)), queued with the `deploy_last_announced` update; payload: `service`, `release`, `commit`, `previous`, `commit_count`, `commits[]` (≤ 40), `deployed_at`, `notes_url` | `release ` | | `live.setup.task_completed` | a go-live setup hub task reads as done for the first time ([server/streaming/setup-progress.js](../server/streaming/setup-progress.js)), queued with its `live_setup_tasks` row; payload: `task`, `user`, `title`, `group`, `completed_at`, `done`, `total`. OpenVibe.Quest turns each task into a quest | `user ` | The chat deploy notice is Chat's: Live queues `live.release.deployed` and OpenVibe.Chat folds and stores the rolling card (C-84). Live keeps no chat table of its own. Live consumes, each on its own endpoint with its own subscription secret, signature v2 only, applied once through the SDK inbox (`idempotency_receipts`): | topics | endpoint | secret | subscribe with | |---|---|---|---| | `openre.session.*` | `/internal/openre-events` | `OPENRE_EVENTS_SECRET` | OpenRestream's `scripts/subscribe-live-events.js` | | `media.vod.*`, `media.clip.*`, `media.storage.*` | `/internal/media-events` | `MEDIA_EVENTS_SECRET` | `scripts/subscribe-media-events.js` | | `network.user.*`, `network.follow.*`, `network.subject.merged`, `network.account.*` | `/internal/network-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --network` | | `billing.transaction.settled`, `billing.entitlement.changed` | `/internal/billing-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --billing` | | `tips.interaction.ready` | `/internal/tips-events` | `LIVE_EVENTS_SECRET` | `scripts/subscribe-media-events.js --tips` | Billing's events ([server/monetization/billing-events.js](../server/monetization/billing-events.js)): "Vibes credited" to a buyer, "Subscribed!" for a provider-paid subscription (with the sub alert on the streamer's PowerChat overlay when PowerChat paid), the channel's celebration for a site-routed PowerChat tip, and the subscription notices (could not auto-renew, renewal failed, ended). OpenVibe.Tips posts its own chat lines to OpenVibe.Chat, so a tip it settled (from openvibe.tips, or on a streamer's own PowerChat through Billing) reaches Live only as `tips.interaction.ready` ([server/tips/tips-events.js](../server/tips/tips-events.js)): Live reads Tips' view of it (`tips.interaction.get`: the public name, no amount when hidden), records it in `channel_tips` and advances the channel's donation goal. Live's own donations (origin `billing`) are left alone. #### Media outcomes over Events Media reports `vod.ready|failed`, `clip.ready|failed` and `storage.alert|recovered` as `media.*` events. It writes each event in the state change's transaction, and Events delivers it to `POST /internal/media-events` (signature v2, `MEDIA_EVENTS_SECRET`). [server/media-proxy/outcomes.js](../server/media-proxy/outcomes.js) applies each outcome once (receipt `media:::`, consumer `live-media-outcomes`), so a redelivery is a no-op. Only Live's tenant (`MEDIA_APP_ID`) counts, and `media.object.uploaded` is ignored. This is the only path. The direct webhook (`POST /internal/media-webhook`, `MEDIA_WEBHOOK_SECRET`) carried the same outcomes during the transition. It was retired on 2026-10-10, after Events had delivered every one of Live's 123 media outcomes with no dead letters: Live's `webhook_url` was cleared in Media's `apps` row and seed, and the route, `server/media-proxy/webhook.js`, `MEDIA_WEBHOOK_SECRET` and `MEDIA_EVENTS_AUTHORITY` were deleted.