# multistreaming Self-hosted live production dashboard: ingest RTMP feeds (one per person from OBS) and restream them to one or more streaming platforms — with shared rooms, role-based collaboration, a zero-knowledge key vault, and scenes & composition (grid/PiP layouts, text/image overlays, per-output audio routing). - **Panel**: React + TypeScript + shadcn/ui (Tailwind v4) at `http://:8080`. - **Ingest**: RTMP at `rtmp://:1935/live/`. - **Fan-out**: FFmpeg, `-c copy` remux by default; re-encode only for composed scenes. --- ## 1. Features - **Per-person feeds** — every streamer publishes to their own ingest URL with a unique stream key. - **Rooms & roles** — share a production with `owner` (full control), `editor` (edits production, never sees keys), and `streamer` (own accounts + start streaming) roles via single-use invite links. - **Pluggable providers** — Twitch, YouTube, Kick, and a custom RTMP escape hatch, extensible in one file. - **Zero-knowledge vault** — stream keys are encrypted in the browser (AES-GCM under a PBKDF2-wrapped vault key); the server stores only ciphertext and grants keys transiently, in memory, when a streamer clicks *Start streaming*. - **Scenes & composition** — named scenes with grid or picture-in-picture layouts, text and image overlays, and per-output audio routing. Activating a scene switches the room from passthrough to a composed program. --- ## 2. Architecture ``` OBS ──RTMP──▶ node-media-server (ingest, :1935) │ publish/unpublish events ▼ index.js ── reconcileAll() │ ┌───────────┴────────────┐ ▼ ▼ passthrough scene (composed) fanout.js (engine) compose.js → fanout.js ffmpeg -c copy ffmpeg -filter_complex (xstack/overlay/drawtext) │ │ └───────────┬────────────┘ ▼ platform push URLs (Twitch/YouTube/Kick/custom, resolved from in-memory grants) ``` | Component | Path | Role | |-----------|------|------| | `src/index.js` | orchestrator | wires config/store/fanout/server; tracks live feeds; `reconcileAll()` | | `src/server.js` | REST API | Express routes, role gating, `/api/state` | | `src/store.js` | persistence | atomic JSON store for users/vaults/rooms/memberships/invites/accounts/feeds/outputs/scenes/sceneOutputs/images | | `src/fanout.js` | process engine | generic unit-of-work engine: spawn/kill/restart/reconcile FFmpeg children | | `src/compose.js` | composition | builds FFmpeg filter graphs + argv for scenes | | `src/vault.js` | crypto | zero-knowledge vault enroll/unlock/recover (WebCrypto) | | `src/grants.js` | grants | in-memory, TTL-bounded plaintext key grants | | `src/auth.js` / `src/oidc.js` | auth | sessions + local scrypt / OIDC PKCE | | `src/providers.js` | providers | pluggable platform definitions | | `src/config.js` | config | environment → runtime config | | `web/src/` | frontend | React/shadcn panel | --- ## 3. Directory layout ``` multistreaming/ ├── src/ # CommonJS backend ├── web/ # React + TS + shadcn frontend (src/, built to web/dist) ├── docs/SCENES.md # scene & composition design contract ├── Dockerfile # multi-stage: web build → backend deps → runtime (node + ffmpeg) ├── metadata.json # catalog metadata (version, compose, env) └── package.json ``` --- ## 4. Configuration Environment variables (see also `metadata.json` for the UI descriptions): | Variable | Default | Purpose | |----------|---------|---------| | `AUTH_MODE` | `oidc` | `oidc` (Authelia) or `local` (dev) | | `OIDC_ISSUER` | — | Authelia root URL (OIDC discovery) | | `OIDC_CLIENT_ID` | `multistreaming` | OIDC client id | | `OIDC_REDIRECT_URI` | — | must match Authelia redirect_uris | | `SESSION_SECRET` | auto | HMAC secret for session cookies (set it to a stable random value) | | `PUBLIC_HOST` | `''` | host shown in copy-paste ingest URLs | | `GRANT_TTL_MS` | `21600000` | plaintext-key grant lifetime (6 h) | | `RTMP_PORT` | `1935` | RTMP ingest port | | `HTTP_PORT` | `8080` | panel + API port | | `DATA_DIR` | `./data` | persisted state + `uploads/` | | `FFMPEG_PATH` | `ffmpeg` | ffmpeg binary | | `SCENE_WIDTH` / `SCENE_HEIGHT` | `1920` / `1080` | composed canvas | | `SCENE_FPS` | `30` | composed frame rate | | `SCENE_VIDEO_BITRATE` | `4500k` | composed video bitrate | | `SCENE_AUDIO_BITRATE` | `160k` | composed audio bitrate | | `SCENE_FONT` | DejaVu Sans | drawtext font path (installed in the image) | | `SCENE_FONT_BOLD` | DejaVu Sans Bold | bold drawtext font path | | `SCENE_MAX_IMAGE_BYTES` | `1048576` | image overlay upload cap (1 MiB) | --- ## 5. Running Build and run with the repository's catalog tooling (metadata.json provides the compose service) or directly: ```bash docker build -t multistreaming . docker run -p 1935:1935 -p 8080:8080 \ -v multistreaming_data:/data \ -e AUTH_MODE=oidc \ -e OIDC_ISSUER=https://auth.example.com \ -e OIDC_REDIRECT_URI=https://streaming.example.com/api/auth/oidc/callback \ -e SESSION_SECRET=… \ multistreaming ``` - Panel: `http://:8080` (put it behind nginx-proxy-manager for TLS). - Ingest: `rtmp://:1935/live/` — RTMP bypasses the HTTP proxy, so keep port 1935 on a trusted network. --- ## 6. Ingest URL model Each **feed** has a unique `streamKey`. Point OBS at: ``` rtmp://:1935/live/ ``` (`PUBLIC_HOST` only affects the copy-paste URL shown in the UI; ingest always hits the container's port 1935.) The `live` app name is fixed (`ingestApp`). When a feed publishes, the orchestrator fans it out to its enabled outputs (or, if a scene is active, feeds the composed program). --- ## 7. Providers Providers are defined in `src/providers.js`. Each extends the `Provider` base class and implements: - `fields` — the fields the UI asks the user for (`name`, `label`, `secret?`, `required?`, `placeholder?`, `help?`), - `defaultUrl` — the platform's RTMP server URL, - `pushUrl(config)` — how the key is appended (default: `url + "/" + key`), - `describe(config)` / `validate(config)` — copy-safe summary + validation. To add a provider, add a derived class and register it in `PROVIDERS`: ```js class NewPlatformProvider extends Provider { constructor() { super({ id: "newplatform", name: "New Platform", defaultUrl: "rtmp://ingest.newplatform.example/live", fields: [{ name: "key", label: "Stream key", secret: true, required: true }], }); } } // …then push `new NewPlatformProvider()` into PROVIDERS. ``` The `custom` provider is a free-form RTMP endpoint for anything not explicitly supported. --- ## 8. Scenes & composition A **scene** is a named composition for a room (see `docs/SCENES.md` for the full contract): - **Layouts** — `grid` (2–4 columns, up to 6 slots) or `pip` (main + a picture-in-picture window). Each slot assigns a feed (or stays empty). Only *live* feeds are laid out; empty/offline slots are skipped and a single live feed is promoted to fullscreen. - **Overlays** — text (position, size, color, bold) and images (position + size, ≤ 1 MiB, PNG/JPEG/WebP). - **Audio routing** — each scene **output** (destination) chooses `program` (mixed audio of all live feeds), `silent`, or a specific `feed`'s audio. - **Activation** — activating a scene switches the room into composed mode (per-feed passthrough pauses); deactivating restores passthrough. Composed video is re-encoded (H.264 + AAC) per destination so each output can carry different audio. The FFmpeg filter graph is generated entirely by `src/compose.js`; no scene data ever stores a stream key. --- ## 9. Security See [SECURITY.md](./SECURITY.md) for the full threat model. Highlights: - Stream keys are encrypted in the browser; the server persists only `secretCiphertext` + `serverWrapped`. - Plaintext keys exist server-side only as short-lived, in-memory grants. - FFmpeg logs redact destination URLs; overlay text cannot inject FFmpeg arguments; image uploads are size/type-limited. - Roles and `maskAccount` ensure editors/streamers never see others' keys. The honest limit: the machine that pushes a stream must hold the key at push time — if the host is compromised during a stream, live keys are readable. --- ## 10. Development No runtime tests are run by design; verification is static. ```bash # backend syntax check node --check src/index.js && node --check src/fanout.js && \ node --check src/server.js && node --check src/store.js && \ node --check src/compose.js && node --check src/config.js # frontend typecheck + build cd web && npm install && npm run build ``` `npm run build` runs `tsc -b && vite build` and emits `web/dist`, which the backend serves at `/`. A `npm run dev` Vite server may be used for manual web inspection during development.