feat: add InternalWHIP component and associated tests

- Implemented the InternalWHIP component for managing WHIP server configurations.
- Added functionality to load live WHIP state from Core and handle OBS URL generation.
- Included polling for active streams and notifying parent components of state changes.
- Created comprehensive tests for the InternalWHIP component covering various scenarios including fallback mechanisms and state changes.

test: add integration tests for WHIP source component

- Developed end-to-end tests for the InternalWHIP component to verify its behavior under different configurations.
- Ensured that the component correctly handles the loading of WHIP state, displays appropriate messages, and emits the correct onChange events.

test: add Settings WHIP configuration tests

- Implemented tests for the WHIP settings tab to validate loading and saving of WHIP configurations.
- Verified that the correct values are sent back to the Core when the user saves changes.
- Ensured that the UI reflects the current state of the WHIP configuration after Core restarts or changes.
This commit is contained in:
Cesar Mendivil 2026-03-14 12:27:53 -07:00
parent 71b3bd9e1d
commit 00e98a19b3
70 changed files with 37802 additions and 4878 deletions

View File

@ -1,3 +1,8 @@
REACT_APP_CORE_URL=https://restreamer.nextream.sytes.net
REACT_APP_CORE_URL=http://192.168.1.15:8080
REACT_APP_WHIP_BASE_URL=http://192.168.1.15:8555
REACT_APP_YTDLP_URL=http://100.73.244.28:8080
REACT_APP_FB_SERVER_URL=http://localhost:3002
REACT_APP_LIVEKIT_API_KEY=APIBTqTGxf9htMK
REACT_APP_LIVEKIT_API_SECRET=0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW
REACT_APP_LIVEKIT_WS_URL=wss://livekit-server.nextream.sytes.net
REACT_APP_WHIP_SERVER_URL=https://djmaster.nextream.sytes.net

2
.gitignore vendored
View File

@ -27,3 +27,5 @@ yarn-error.log*
messages.mo
.eslintcache
.idea/
.playwright-mcp/

View File

@ -86,6 +86,28 @@ handle /oauth/facebook/callback {
redir /oauth/facebook/callback.html{query} 302
}
# ── LiveKit Ingress WHIP proxy: OBS publica vía WHIP al mismo dominio ─────────
# OBS usa: https://djmaster.nextream.sytes.net/w/<streamKey>
# Caddy lo reenvía al servicio livekit-ingress interno (solo accesible localmente).
# LIVEKIT_INGRESS_HOST se configura en docker-compose (p.ej. 192.168.1.20:8088).
handle /w/* {
reverse_proxy {env.LIVEKIT_INGRESS_HOST} {
header_up Host {upstream_hostport}
}
}
# ── WHIP info API: genera sesión Ingress (Node en :3002) ─────────────────────
handle /api/whip/* {
reverse_proxy 127.0.0.1:3002
}
# ── WHEP relay proxy: Core hace pull aquí egress server ───────────────────
# Core input: https://djmaster.nextream.sytes.net/whep/rooms/<channelId>
# EGRESS_HOST se configura en docker-compose (URL del servidor egress).
handle /whep/* {
reverse_proxy {env.EGRESS_HOST}
}
# SPA serve static files, fallback to index.html for client-side routing
handle {
root * /ui/build

View File

@ -0,0 +1,409 @@
# Prompt de integración WHIP para Restreamer UI
> Este documento es el brief técnico completo para adaptar la UI de Restreamer de modo que exponga el servidor WHIP del Core de la misma manera que ya expone RTMP Server. Incluye dos casos de uso: **WHIP Server** (panel de estado + URL para OBS) y **WHIP Receive mode** (fuente de entrada en un proceso).
---
## Contexto del Core
El Core de datarhei expone las siguientes APIs relevantes:
### Config (`GET /api/v3/config`)
La sección `whip` dentro de `config` tiene esta forma:
```json
"whip": {
"enable": true,
"address": ":8555",
"token": ""
}
```
El campo `address` puede ser `:8555` (solo puerto) o `host:8555`.
### Canales activos (`GET /api/v3/whip`)
Devuelve la lista de streams que OBS/clientes están publicando en este momento:
```json
[
{
"name": "9VGyRCkCVCgj",
"published_at": "2026-03-14T02:51:36.799550551Z"
}
]
```
### URL de publicación WHIP (para OBS)
```
http://<host>:<whip_port>/whip/<stream_key>
```
Ejemplo: `http://192.168.1.15:8555/whip/mistream`
### URL de relay SDP (para FFmpeg / proceso interno)
```
http://localhost:<whip_port>/whip/<stream_key>/sdp
```
Esta es la URL que el Core inyecta automáticamente con `{whip}` como placeholder de input.
### Placeholder de proceso
En el config de un proceso de FFmpeg, el input address puede usar:
```
{whip:name=<stream_key>}
```
El Core lo expande a `http://localhost:<whip_port>/whip/<stream_key>/sdp` y agrega automáticamente `-protocol_whitelist file,crypto,http,rtp,udp,tcp` en las input options.
---
## Parte 1 — Panel "WHIP Server" (equivalente a "RTMP Server")
### Dónde ubicarlo
En la misma sección de configuración/status donde aparece **RTMP Server** y **SRT Server**. Añadir una tarjeta o sección llamada **WHIP Server**.
### Comportamiento esperado
#### 1.1 Indicador de estado
- Leer `config.whip.enable` de `GET /api/v3/config`
- Mostrar badge **Enabled** (verde) / **Disabled** (gris)
- Mostrar el puerto: extraer de `config.whip.address` (ej. `:8555``8555`)
#### 1.2 Campo "Stream URL para OBS"
Construir la URL de publicación a mostrar:
```
http://<host_público>:<whip_port>/whip/
```
- `host_público`: usar `config.host.name[0]` si `config.host.auto === true`, o el primer valor de `config.host.name`
- `whip_port`: extraer de `config.whip.address`
- Mostrar un campo de texto **read-only** con botón **Copiar**
- Indicar al usuario que al final de la URL debe agregar su **stream key** (ej. `mistream`)
Ejemplo visual:
```
┌─────────────────────────────────────────────────────┐
│ WHIP Server URL │
│ http://192.168.1.15:8555/whip/ [Copiar] │
│ Ingresá tu stream key al final de la URL │
└─────────────────────────────────────────────────────┘
```
#### 1.3 Campo "Stream Key"
- Input de texto editable donde el usuario escribe su stream key (ej. `mistream`, `obs-live`, etc.)
- Al escribirla, actualizar dinámicamente la URL completa a copiar:
`http://192.168.1.15:8555/whip/mistream`
- Botón **Copiar URL completa**
#### 1.4 Token de autenticación (opcional)
- Si `config.whip.token` no está vacío, mostrar un aviso: "Este servidor requiere un token. Agregá `?token=<token>` al final de la URL."
- Opcionalmente, ofrecer un checkbox "Incluir token en la URL" que lo appende automáticamente.
#### 1.5 Tabla de streams activos
- Hacer polling a `GET /api/v3/whip` cada 5 segundos
- Mostrar una tabla con columnas: **Stream Key** | **Publicando desde**
- Si la lista está vacía, mostrar "Sin streams activos"
```
┌──────────────────┬──────────────────────┐
│ Stream Key │ Publicando desde │
├──────────────────┼──────────────────────┤
│ 9VGyRCkCVCgj │ hace 3 minutos │
│ mistream │ hace 12 segundos │
└──────────────────┴──────────────────────┘
```
#### 1.6 Configuración WHIP (settings)
Sección colapsable o en la misma pantalla de configuración del Core:
- Toggle **Habilitar WHIP Server** → modifica `config.whip.enable` via `PATCH /api/v3/config`
- Campo **Puerto** → modifica `config.whip.address`
- Campo **Token** → modifica `config.whip.token`
---
## Parte 2 — Modo de recepción WHIP ("Receive mode")
### Dónde ubicarlo
En el wizard de creación/edición de un proceso (input source selector), junto a las opciones existentes como:
- Network Source (HLS, RTP, RTSP, etc.)
- RTMP Source
- SRT Source
- → **WHIP Source** ← (nueva opción)
### Comportamiento esperado
#### 2.1 Selector de protocolo
En la lista de protocolos de entrada agregar la opción:
```
WHIP (WebRTC HTTP Ingestion Protocol)
```
#### 2.2 Formulario de configuración de la fuente WHIP
Al seleccionar WHIP como fuente, mostrar:
**Stream Key** — campo de texto editable
(Se recomienda usar el ID del proceso por defecto, o dejar que el usuario lo cambie)
**URL de publicación para OBS** (campo read-only + botón Copiar):
```
http://<host_público>:<whip_port>/whip/<stream_key>
```
Actualiza en tiempo real mientras el usuario escribe el stream key.
```
┌──────────────────────────────────────────────────────┐
│ Protocolo de entrada: WHIP │
│ │
│ Stream Key: [mistream ] │
│ │
│ URL para OBS (copiar en OBS → Servicio WHIP): │
│ http://192.168.1.15:8555/whip/mistream [Copiar] │
│ │
│ Estado: ● Esperando publicador... │
│ ✓ Transmitiendo (si hay publisher activo) │
└──────────────────────────────────────────────────────┘
```
#### 2.3 Indicador de estado en tiempo real
- Consultar `GET /api/v3/whip` cada 5 segundos
- Si `name === stream_key` aparece en la lista → mostrar "✓ Transmitiendo" (verde)
- Si no → "● Esperando publicador..." (gris/naranja)
#### 2.4 Address interno del proceso
Cuando el usuario guarda el proceso, el `input.address` debe configurarse como:
```
{whip:name=<stream_key>}
```
El Core lo expande a `http://localhost:<whip_port>/whip/<stream_key>/sdp` y agrega `-protocol_whitelist` automáticamente.
Si la UI no soporta el placeholder `{whip}`, puede usar directamente:
```
http://localhost:<whip_port>/whip/<stream_key>/sdp
```
Y agregar en `input.options` (ANTES de la URL, es decir, al principio del array):
```json
["-protocol_whitelist", "file,crypto,http,rtp,udp,tcp"]
```
**IMPORTANTE**: `-protocol_whitelist file,crypto,http,rtp,udp,tcp` debe estar presente para que FFmpeg pueda abrir los sub-protocolos RTP/UDP anidados dentro de la URL HTTP. Sin esta opción el probe devuelve `0x0 none`.
#### 2.5 Probe automático
Cuando el usuario hace clic en "Probe" o "Detectar streams":
- La UI puede invocar `GET /api/v3/process/<id>/probe` si el proceso ya existe
- O construir un payload de probe temporal equivalente
- El resultado correcto debe ser: `Video: h264, yuv420p, 1920x1080, 30fps` + `Audio: opus, 48000 Hz, stereo`
---
## Parte 3 — API endpoints completos de WHIP
### 3.1 `GET /api/v3/whip`
Lista todos los publishers activos en este momento.
**Response `200`:**
```json
[
{
"name": "mistream",
"published_at": "2026-03-14T02:51:36.799550551Z"
}
]
```
Usar con polling cada 5s para el panel de streams activos y el indicador de estado en Receive mode.
---
### 3.2 `GET /api/v3/whip/url` ⭐ nuevo
Devuelve la URL base del servidor WHIP y toda la info que la UI necesita para el panel "WHIP Server".
**Response `200`:**
```json
{
"base_publish_url": "http://192.168.1.15:8555/whip/",
"base_sdp_url": "http://localhost:8555/whip/",
"has_token": false,
"example_obs_url": "http://192.168.1.15:8555/whip/<stream-key>",
"input_address_template": "{whip:name=<stream-key>}"
}
```
- `base_publish_url` → mostrar en el panel WHIP Server; el usuario agrega su stream key al final
- `has_token` → si es `true`, mostrar aviso de token y el campo para incluirlo en la URL
- `example_obs_url` → texto de ayuda con placeholder visual
- `input_address_template` → usar como `input.address` al crear un proceso con WHIP como fuente
---
### 3.3 `GET /api/v3/whip/:name/url` ⭐ nuevo
Devuelve la URL completa de publicación para un stream key específico. **Este es el endpoint principal para generar la URL que el usuario copia en OBS.**
**Ejemplo:** `GET /api/v3/whip/mistream/url`
**Response `200`:**
```json
{
"publish_url": "http://192.168.1.15:8555/whip/mistream",
"sdp_url": "http://localhost:8555/whip/mistream/sdp",
"stream_key": "mistream"
}
```
- `publish_url` → campo read-only en la UI con botón **Copiar**. Es exactamente la URL que se pega en OBS → Configuración → Transmisión → Servidor.
- `sdp_url` → URL interna para FFmpeg (referencia, no se muestra al usuario en general).
- `stream_key` → confirmación del key procesado.
**Flujo de uso en la UI:**
1. Usuario escribe el stream key en el campo de texto
2. UI llama `GET /api/v3/whip/<stream_key>/url`
3. UI muestra `publish_url` en campo read-only con botón Copiar
4. Usuario copia y pega en OBS
---
### 3.4 `GET /api/v3/config`
Leer configuración del servidor WHIP para el panel de settings.
**Campos relevantes en la respuesta:**
```json
{
"config": {
"host": {
"name": ["192.168.1.15"],
"auto": true
},
"whip": {
"enable": true,
"address": ":8555",
"token": ""
}
}
}
```
---
### 3.5 `PATCH /api/v3/config` (o `PUT`)
Modificar configuración WHIP desde el panel de settings.
**Body:**
```json
{
"whip": {
"enable": true,
"address": ":8555",
"token": "mi-token-secreto"
}
}
```
---
### 3.6 `POST /api/v3/process`
Crear un proceso con WHIP como fuente de entrada.
**Body:**
```json
{
"id": "mi-proceso",
"reference": "mi-proceso",
"input": [
{
"id": "in",
"address": "{whip:name=mistream}",
"options": []
}
],
"output": [
{
"id": "out",
"address": "rtmp://...",
"options": ["-c", "copy", "-f", "flv"]
}
],
"options": ["-loglevel", "level+info"]
}
```
> El Core expande `{whip:name=mistream}``http://localhost:8555/whip/mistream/sdp`
> e inyecta automáticamente `-protocol_whitelist file,crypto,http,rtp,udp,tcp`.
Si la UI no usa el placeholder `{whip}`, usar directamente:
```json
{
"address": "http://localhost:8555/whip/mistream/sdp",
"options": ["-protocol_whitelist", "file,crypto,http,rtp,udp,tcp"]
}
```
---
### 3.7 `GET /api/v3/process/:id/probe`
Obtener información del stream (resolución, codec, fps) una vez el proceso existe.
**Response `200` (cuando OBS está transmitiendo):**
```json
{
"streams": [
{
"type": "video",
"codec": "h264",
"width": 1920,
"height": 1080,
"fps": 30,
"pix_fmt": "yuv420p"
},
{
"type": "audio",
"codec": "opus",
"sampling_hz": 48000,
"layout": "stereo",
"channels": 2
}
]
}
```
---
### Tabla resumen
| Endpoint | Método | Cuándo usarlo |
|---|---|---|
| `/api/v3/whip` | GET | Polling streams activos (cada 5s) |
| `/api/v3/whip/url` | GET | Panel WHIP Server — info base del servidor |
| `/api/v3/whip/:name/url` | GET | **Generar URL para OBS** dado un stream key |
| `/api/v3/config` | GET | Leer estado enable/disable, puerto, token |
| `/api/v3/config` | PATCH | Cambiar configuración desde settings |
| `/api/v3/process` | POST | Crear proceso con WHIP como input |
| `/api/v3/process/:id/probe` | GET | Detectar resolución/codec del stream |
---
## Parte 4 — Instrucciones para el usuario en la UI
### En el panel WHIP Server
```
Cómo transmitir desde OBS:
1. Abrí OBS → Configuración → Transmisión
2. Servicio: Personalizado
3. Servidor: http://<host>:<puerto>/whip/<tu-stream-key>
4. Clave de retransmisión: (dejar vacío)
5. Hacé clic en "Iniciar transmisión"
```
### En el Receive mode WHIP
```
Ingresá tu stream key, copiá la URL y configurala en OBS.
El proceso comenzará a recibir video cuando OBS empiece a transmitir.
```
---
## Notas técnicas para el desarrollador de la UI
1. **Compatibilidad de OBS**: OBS 30+ soporta WHIP nativo. En Configuración → Transmisión, seleccionar "Servicio: Personalizado" y pegar la URL completa.
2. **ICE / WebRTC**: El Core maneja ICE + DTLS-SRTP internamente. La UI no necesita gestionar nada de WebRTC, solo mostrar la URL HTTP.
3. **Protocolo WHIP**: El cliente (OBS) hace un `POST /whip/<key>` con SDP offer → el Core responde con SDP answer → ICE handshake → fluye RTP → el Core lo relay internamente a FFmpeg.
4. **`protocol_whitelist`**: Es obligatorio cuando FFmpeg lee la URL `/whip/<key>/sdp`. El Core lo inyecta automáticamente cuando detecta la URL en el config del proceso, pero si la UI construye el comando FFmpeg directamente debe asegurarse de incluirlo.
5. **Token de autenticación**: Si `config.whip.token` está configurado, la URL de publicación para OBS debe incluirlo como query param: `?token=<value>`. El OBS lo enviará en cada request al WHIP endpoint.
6. **Puertos**: Por defecto el WHIP server escucha en `:8555` (HTTP, sin TLS). El Core HTTP principal escucha en `:8080`. Son servidores independientes.
7. **Múltiples publishers**: El servidor WHIP soporta múltiples stream keys simultáneos. Cada stream key es independiente y genera su propio relay interno.

View File

@ -17,8 +17,8 @@ services:
# ── yt-dlp / stream extractor ──────────────────────────────────────────
# Host:puerto del servicio extractor (usado por Caddy para reverse_proxy).
# Caddy expondrá el servicio en http://localhost:3000/yt-stream/
YTDLP_HOST: "100.73.244.28:8080"
#YTDLP_HOST: "192.168.1.20:8282"
#YTDLP_HOST: "100.73.244.28:8080"
YTDLP_HOST: "192.168.1.20:8282"
# YTDLP_URL: URL completa del servicio yt-dlp vista desde el NAVEGADOR.
# Dejar vacío → la UI usará /yt-stream/ (Caddy proxy, mismo origen = sin CORS).
YTDLP_URL: ""
@ -51,7 +51,23 @@ services:
LIVEKIT_API_KEY: "APIBTqTGxf9htMK"
LIVEKIT_API_SECRET: "0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW"
LIVEKIT_WS_URL: "wss://livekit-server.nextream.sytes.net"
# ── Servidor egress (WHIP ingest + WHEP relay) ─────────────────────────────
# URL del servidor egress / dominio del UI.
# El Core usa esta URL para WHEP pull: <WHIP_SERVER_URL>/whep/rooms/<channelId>
# Caddy proxea /whep/* → egress y /w/* → livekit-ingress, todo bajo el mismo dominio.
WHIP_SERVER_URL: "https://djmaster.nextream.sytes.net"
# ── LiveKit Ingress WHIP (proxy interno) ──────────────────────────────────
# Host:puerto interno del servicio livekit-ingress.
# Caddy hace proxy /w/* → este host (OBS nunca lo ve directamente).
LIVEKIT_INGRESS_HOST: "192.168.1.20:8088"
LIVEKIT_INGRESS_INTERNAL_URL: "http://192.168.1.20:8088"
# URL pública del UI. Se usa para construir la WHIP URL que ve OBS.
# Dejar vacío = se auto-detecta del Host header de cada request.
UI_BASE_URL: "https://djmaster.nextream.sytes.net"
# ── Egress server WHEP (proxy interno) ──────────────────────────────────
# Caddy hace proxy /whep/* → este host.
# Core input: https://djmaster.nextream.sytes.net/whep/rooms/<channelId>
EGRESS_HOST: "llmchats-whep.zuqtxy.easypanel.host"
volumes:
# Persistencia de tokens OAuth2 (Facebook, YouTube, etc.)
- restreamer-ui-fb-data:/data/fb

View File

@ -23,6 +23,12 @@ cat "$CONFIG_FILE"
# ── Set YTDLP_HOST for Caddy reverse_proxy (default: external service or localhost) ─
export YTDLP_HOST="${YTDLP_HOST:-192.168.1.20:8282}"
# ── Set LIVEKIT_INGRESS_HOST for Caddy reverse_proxy (/w/* → livekit-ingress) ─
export LIVEKIT_INGRESS_HOST="${LIVEKIT_INGRESS_HOST:-192.168.1.20:8088}"
# ── Set EGRESS_HOST for Caddy reverse_proxy (/whep/* → egress server) ──────
export EGRESS_HOST="${EGRESS_HOST:-llmchats-whep.zuqtxy.easypanel.host}"
# ── Persist FB data directory ─────────────────────────────────────────────────
mkdir -p /data/fb
export FB_DATA_DIR="${FB_DATA_DIR:-/data/fb}"
@ -39,6 +45,8 @@ FFMPEG_BIN="${FFMPEG_BIN:-ffmpeg}" \
LIVEKIT_API_KEY="${LIVEKIT_API_KEY:-}" \
LIVEKIT_API_SECRET="${LIVEKIT_API_SECRET:-}" \
LIVEKIT_WS_URL="${LIVEKIT_WS_URL:-}" \
LIVEKIT_INGRESS_INTERNAL_URL="${LIVEKIT_INGRESS_INTERNAL_URL:-http://192.168.1.20:8088}" \
UI_BASE_URL="${UI_BASE_URL:-}" \
node /ui/server/index.js &
FB_PID=$!
echo "[entrypoint] FB server PID: $FB_PID"

26670
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -32,6 +32,7 @@
"eslint": "^9.9.1",
"handlebars": "^4.7.8",
"jwt-decode": "^4.0.0",
"livekit-server-sdk": "^2.15.0",
"make-plural": "^7.4.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",

View File

@ -24,7 +24,9 @@ window.__RESTREAMER_CONFIG__ = {
CORE_ADDRESS: '',
YTDLP_URL: '',
FB_SERVER_URL: '',
// URL pública del servidor egress (WHIP ingest + WHEP relay).
// Ej: 'https://llmchats-whep.zuqtxy.easypanel.host'
WHIP_SERVER_URL: '',
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

BIN
restreamer-ui-final.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

57
scripts/check_relay.sh Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: ./scripts/check_relay.sh <CHANNEL_ID>
# Relies on .env REACT_APP_CORE_URL and optional API_TOKEN
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs) || true
fi
CORE_URL=${REACT_APP_CORE_URL:-http://localhost:8080}
API_TOKEN=${API_TOKEN:-}
if [ "$#" -lt 1 ]; then
echo "Usage: $0 <CHANNEL_ID>"
exit 2
fi
CHANNEL_ID=$1
AUTH_HEADER=()
if [ -n "$API_TOKEN" ]; then
AUTH_HEADER+=( -H "Authorization: Bearer ${API_TOKEN}" )
fi
echo "Checking relay and ingest state for channel: ${CHANNEL_ID} (CORE: ${CORE_URL})"
echo -e "\n1) WebRTC relay (node) status -> /webrtc-relay/status"
curl -s ${AUTH_HEADER[@]} "${CORE_URL}/webrtc-relay/status" | jq '.' || echo "(no response)"
echo -e "\n2) RTMP channels -> /api/v3/rtmp"
curl -s ${AUTH_HEADER[@]} "${CORE_URL}/api/v3/rtmp" | jq '.' || echo "(no response)"
echo -e "\n3) Active sessions (ffmpeg,hls,rtmp,srt) -> /api/v3/session/active"
curl -s ${AUTH_HEADER[@]} -X POST "${CORE_URL}/api/v3/metrics" -H 'Content-Type: application/json' -d '{"query":{},"range":{}}' >/dev/null 2>&1 || true
# preferred: /api/v3/session/active?collectors=ffmpeg,hls,rtmp,srt
curl -s ${AUTH_HEADER[@]} "${CORE_URL}/api/v3/session/active?collectors=ffmpeg,hls,rtmp,srt" | jq '.' || echo "(no response)"
echo -e "\n4) Processes referencing channel -> /api/v3/process?reference=${CHANNEL_ID}"
curl -s ${AUTH_HEADER[@]} "${CORE_URL}/api/v3/process?reference=${CHANNEL_ID}" | jq '.' || echo "(no response)"
echo -e "\n5) Ingest process state/report (if any)"
# Try to extract ingest process id from process list
proc_ids=$(curl -s ${AUTH_HEADER[@]} "${CORE_URL}/api/v3/process?reference=${CHANNEL_ID}" | jq -r '.[]?.id' || true)
if [ -n "$proc_ids" ]; then
for id in $proc_ids; do
echo -e "\n--- process: $id ---"
curl -s ${AUTH_HEADER[@]} "${CORE_URL}/api/v3/process/${id}/state" | jq '.' || echo "(no state)"
curl -s ${AUTH_HEADER[@]} "${CORE_URL}/api/v3/process/${id}/report" | jq '.' || echo "(no report)"
done
else
echo "No processes found for reference ${CHANNEL_ID}"
fi
echo -e "\n6) MemFS files for channel -> /api/v3/fs/mem?glob=/${CHANNEL_ID}*"
curl -s ${AUTH_HEADER[@]} "${CORE_URL}/api/v3/fs/mem?glob=/%2F${CHANNEL_ID}%2A" | jq '.' || echo "(no response)"
echo -e "\nHint: Check server logs (node) for [webrtc-relay] and core logs for ffmpeg/process messages. If using Docker, run: docker-compose logs -f"

View File

@ -0,0 +1,97 @@
'use strict';
const http = require('http');
const https = require('https');
const { IngressClient, IngressInput } = require('/home/xesar/WebstormProjects/restreamer-ui-v2/node_modules/livekit-server-sdk');
const LK_API_KEY = 'APIBTqTGxf9htMK';
const LK_API_SECRET = '0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW';
const LK_HTTP_URL = 'https://livekit-server.nextream.sytes.net';
const INGRESS_HOST = '192.168.1.20';
const INGRESS_PORT = 8088;
const sdp = [
'v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=-', 't=0 0',
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'c=IN IP4 0.0.0.0',
'a=sendonly', 'a=rtpmap:96 H264/90000', 'a=mid:0',
].join('\r\n') + '\r\n';
function post(path, extraHeaders) {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: INGRESS_HOST, port: INGRESS_PORT,
path, method: 'POST',
headers: {
'Content-Type': 'application/sdp',
'Content-Length': Buffer.byteLength(sdp),
...extraHeaders,
},
timeout: 8000,
}, (r) => {
let d = '';
r.on('data', (c) => (d += c));
r.on('end', () => resolve({ status: r.statusCode, headers: r.headers, body: d.slice(0, 300) }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.write(sdp);
req.end();
});
}
function options(path) {
return new Promise((resolve, reject) => {
const req = http.request({
hostname: INGRESS_HOST, port: INGRESS_PORT,
path, method: 'OPTIONS',
headers: { 'Access-Control-Request-Method': 'POST' },
timeout: 5000,
}, (r) => {
let d = '';
r.on('data', (c) => (d += c));
r.on('end', () => resolve({ status: r.statusCode, headers: r.headers, body: d.slice(0, 200) }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.end();
});
}
(async () => {
console.log(`Conectando a livekit-ingress en ${INGRESS_HOST}:${INGRESS_PORT}\n`);
const client = new IngressClient(LK_HTTP_URL, LK_API_KEY, LK_API_SECRET);
const ingress = await client.createIngress(IngressInput.WHIP_INPUT, {
name: 'test-direct', roomName: 'test', participantIdentity: 'obs', participantName: 'OBS',
});
const sk = ingress.streamKey;
console.log('stream_key :', sk);
console.log('ingress.url:', ingress.url);
// OPTIONS
console.log('\n[OPTIONS /w]');
const o1 = await options('/w').catch(e => ({ status: 'ERR', body: e.message }));
console.log(' status:', o1.status, '| allow:', o1.headers?.allow || '-');
console.log('\n[OPTIONS /w/' + sk + ']');
const o2 = await options('/w/' + sk).catch(e => ({ status: 'ERR', body: e.message }));
console.log(' status:', o2.status, '| allow:', o2.headers?.allow || '-');
// POST variants
const variants = [
{ label: 'POST /w (sin auth)', path: '/w', headers: {} },
{ label: 'POST /w/'+sk+' (sk en URL)', path: '/w/'+sk, headers: {} },
{ label: 'POST /w Bearer sk', path: '/w', headers: { Authorization: 'Bearer ' + sk } },
{ label: 'POST /w/'+sk+' Bearer sk (sk en URL + auth)', path: '/w/'+sk, headers: { Authorization: 'Bearer ' + sk } },
];
for (const v of variants) {
console.log('\n[' + v.label + ']');
const r = await post(v.path, v.headers).catch(e => ({ status: 'ERR', body: e.message }));
console.log(' status:', r.status, '| body:', r.body?.slice(0, 120));
}
await client.deleteIngress(ingress.ingressId).catch(() => {});
console.log('\nIngress eliminado.');
})().catch((e) => { console.error(e.message); process.exit(1); });

44
scripts/test-twirp.js Normal file
View File

@ -0,0 +1,44 @@
'use strict';
const { AccessToken } = require('../node_modules/livekit-server-sdk');
const https = require('https');
(async () => {
const at = new AccessToken(
'APIBTqTGxf9htMK',
'0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW',
{ ttl: 3600 },
);
at.addGrant({ roomCreate: true, roomList: true, roomAdmin: true, ingressAdmin: true });
const token = await at.toJwt();
const body = JSON.stringify({
inputType: 1,
name: 'test-twirp',
roomName: 'test-room',
participantIdentity: 'obs',
participantName: 'OBS',
});
const options = {
hostname: 'livekit-server.nextream.sytes.net',
path: '/twirp/livekit.Ingress/CreateIngress',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + token,
'Content-Length': Buffer.byteLength(body),
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => {
console.log('Status:', res.statusCode);
console.log(data);
});
});
req.on('error', (e) => console.error(e.message));
req.write(body);
req.end();
})();

230
scripts/test-whip-e2e.js Normal file
View File

@ -0,0 +1,230 @@
#!/usr/bin/env node
/**
* E2E: Valida la generación de URL WHIP via LiveKit Ingress.
*
* Ejecuta 3 pruebas en secuencia:
* 1. Conectividad directa al LiveKit Server (HTTPS)
* 2. IngressClient.createIngress() directo (sin pasar por la UI)
* 3. POST /api/whip/info al servidor Node de la UI
*
* Uso:
* node scripts/test-whip-e2e.js
* node scripts/test-whip-e2e.js http://localhost:3000 # UI en otro host
*
* Sin argumentos usa las vars de entorno del docker-compose o sus defaults.
*/
'use strict';
const http = require('http');
const https = require('https');
// ── Configuración (mirrors docker-compose.yml) ─────────────────────────────
const LK_API_KEY = process.env.LIVEKIT_API_KEY || 'APIBTqTGxf9htMK';
const LK_API_SECRET = process.env.LIVEKIT_API_SECRET || '0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW';
const LK_WS_URL = process.env.LIVEKIT_WS_URL || 'wss://livekit-server.nextream.sytes.net';
const LK_HTTP_URL = LK_WS_URL.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://');
const UI_HOST = (process.argv[2] || 'http://localhost:3002').replace(/\/+$/, '');
const ROOM = 'e2e-whip-test-' + Date.now();
// ── Helpers ────────────────────────────────────────────────────────────────
let failures = 0;
function ok(msg) { console.log(`${msg}`); }
function fail(msg) { console.error(`${msg}`); failures++; }
function section(title) { console.log(`\n━━ ${title} ━━`); }
function httpRequest(url, { method = 'GET', body, headers = {} } = {}) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const raw = body ? JSON.stringify(body) : undefined;
const reqOpts = {
hostname: parsed.hostname,
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
path: parsed.pathname + (parsed.search || ''),
method,
headers: {
...headers,
...(raw ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(raw) } : {}),
},
// Allow self-signed certs for local testing
rejectUnauthorized: false,
};
const req = lib.request(reqOpts, (res) => {
let buf = '';
res.on('data', (c) => { buf += c; });
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: buf }));
});
req.on('error', reject);
if (raw) req.write(raw);
req.end();
});
}
// ── Test 1: Conectividad al LiveKit Server ─────────────────────────────────
async function testLiveKitConnectivity() {
section('Test 1: Conectividad a LiveKit Server');
console.log(` URL: ${LK_HTTP_URL}`);
try {
const r = await httpRequest(`${LK_HTTP_URL}/`, { method: 'GET' });
// LiveKit devuelve 404 o 405 en /, pero cualquier respuesta HTTP confirma conectividad
if (r.status < 600) {
ok(`LiveKit Server responde HTTP ${r.status} (conectividad OK)`);
} else {
fail(`Respuesta inesperada: ${r.status}`);
}
} catch (err) {
fail(`No se puede conectar a ${LK_HTTP_URL}: ${err.message}`);
console.log(' → Verifica que livekit-server.nextream.sytes.net sea accesible');
}
}
// ── Test 2: IngressClient directo (bypass UI) ──────────────────────────────
async function testIngressClientDirect() {
section('Test 2: IngressClient.createIngress() directo');
console.log(` API Key: ${LK_API_KEY}`);
console.log(` LK Server: ${LK_HTTP_URL}`);
console.log(` Room: ${ROOM}`);
// Construir JWT manualmente usando mismo algoritmo que livekit-server-sdk
// para aislar si el problema es de credenciales o de config del server.
try {
const { IngressClient, IngressInput } = require(require('path').resolve(__dirname, '../server/node_modules/livekit-server-sdk'));
const client = new IngressClient(LK_HTTP_URL, LK_API_KEY, LK_API_SECRET);
const ingress = await client.createIngress(IngressInput.WHIP_INPUT, {
name: 'e2e-test',
roomName: ROOM,
participantIdentity: 'e2e-obs',
participantName: 'E2E OBS',
});
ok(`createIngress() OK → ingressId: ${ingress.ingressId}`);
ok(`streamKey: ${ingress.streamKey}`);
ok(`ingress.url (reportada por LiveKit): ${ingress.url}`);
// Cleanup: intentar borrar el ingress creado
try {
await client.deleteIngress(ingress.ingressId);
ok(`Ingress de prueba eliminado`);
} catch (_) {}
return ingress;
} catch (err) {
fail(`createIngress() falló: ${err.message}`);
if (err.message.includes('401') || err.message.includes('Unauthorized') || err.message.includes('JWT')) {
console.log('\n ── Diagnóstico JWT ───────────────────────────────────────');
console.log(' El error 401/JWT indica que las credenciales API no coinciden');
console.log(' con las que tiene configurado el LiveKit Server.\n');
console.log(' Verifica en tu livekit-server.yaml:');
console.log(' key_file: <ruta>');
console.log(' o');
console.log(' keys:');
console.log(` ${LK_API_KEY}: <secret>`);
console.log(`\n El secret configurado en la UI es: ${LK_API_SECRET}`);
console.log(' Debe coincidir EXACTAMENTE con el que tiene livekit-server.');
}
if (err.message.includes('not found') || err.message.includes('ENOTFOUND')) {
console.log(' → El hostname no resuelve. Verifica DNS / VPN.');
}
return null;
}
}
// ── Test 3: POST /api/whip/info via Node server ────────────────────────────
async function testWhipInfoEndpoint() {
section(`Test 3: POST ${UI_HOST}/api/whip/info`);
try {
const r = await httpRequest(`${UI_HOST}/api/whip/info`, {
method: 'POST',
body: { room: ROOM, identity: 'e2e-test', name: 'E2E Test' },
});
let data;
try { data = JSON.parse(r.body); } catch { data = { _raw: r.body }; }
if (r.status === 200) {
ok(`HTTP 200`);
ok(`whipUrl: ${data.whipUrl}`);
ok(`streamKey: ${data.streamKey}`);
ok(`ingressId: ${data.ingressId}`);
// Validaciones de la URL
try {
const u = new URL(data.whipUrl);
ok(`whipUrl es URL válida (protocol: ${u.protocol})`);
if (data.whipUrl.includes('192.168.')) {
fail(`whipUrl contiene IP privada — OBS externo no puede acceder`);
} else {
ok(`whipUrl no contiene IP privada`);
}
if (data.whipUrl.endsWith(data.streamKey)) {
ok(`whipUrl termina en streamKey`);
} else {
fail(`whipUrl NO termina en streamKey (verifica la construcción)`);
}
} catch {
fail(`whipUrl no es URL válida: ${data.whipUrl}`);
}
} else if (r.status === 404 || r.status === 405) {
fail(`HTTP ${r.status} — la ruta /api/whip/info no existe en el servidor`);
console.log(' → Verifica que el servidor Node (port 3002) tenga la ruta registrada');
console.log(' → Y que Caddy tenga el bloque "handle /api/whip/*"');
} else {
fail(`HTTP ${r.status}: ${JSON.stringify(data)}`);
}
} catch (err) {
fail(`Request falló: ${err.message}`);
console.log(` → Verifica que el servidor esté corriendo en ${UI_HOST}`);
}
}
// ── Test 4: Proxy Caddy /api/whip/* (si se pasa URL del UI en :3000) ───────
async function testCaddyProxy() {
if (!process.argv[2]) return; // Solo si se especificó UI_HOST explícitamente
section(`Test 4: Caddy proxy POST ${process.argv[2]}/api/whip/info`);
try {
const r = await httpRequest(`${process.argv[2]}/api/whip/info`, {
method: 'POST',
body: { room: ROOM + '-caddy', identity: 'e2e-caddy', name: 'E2E Caddy' },
});
const data = JSON.parse(r.body).catch?.() || JSON.parse(r.body);
if (r.status === 200) {
ok(`Caddy proxy OK → whipUrl: ${data.whipUrl}`);
} else {
fail(`Caddy proxy HTTP ${r.status}: ${r.body.slice(0, 200)}`);
}
} catch (err) {
fail(`Caddy proxy error: ${err.message}`);
}
}
// ── Main ───────────────────────────────────────────────────────────────────
(async () => {
console.log('\n╔══════════════════════════════════════╗');
console.log('║ WHIP Ingress E2E Test Suite ║');
console.log('╚══════════════════════════════════════╝');
console.log(` Fecha: ${new Date().toISOString()}`);
// Check livekit-server-sdk disponible (busca en server/node_modules)
const sdkPath = require('path').resolve(__dirname, '../server/node_modules/livekit-server-sdk');
try {
require(sdkPath);
} catch {
console.error('\n✗ livekit-server-sdk no encontrado en server/node_modules.');
console.error(' Ejecuta: cd server && npm install');
process.exit(1);
}
await testLiveKitConnectivity();
await testIngressClientDirect();
await testWhipInfoEndpoint();
await testCaddyProxy();
console.log('\n━━ Resultado final ━━');
if (failures === 0) {
console.log(' ✅ TODOS los tests pasaron\n');
} else {
console.log(`${failures} test(s) fallaron — revisa los errores arriba\n`);
process.exit(1);
}
})();

View File

@ -0,0 +1,299 @@
#!/usr/bin/env node
/**
* E2E: Valida que la URL WHIP generada para OBS funcione correctamente.
*
* Pruebas:
* 1. Crear Ingress en LiveKit (Twirp HTTP API, igual que el browser)
* 2. Construir la URL pública (djmaster.../w/<stream_key>)
* 3. OPTIONS al endpoint WHIP OBS hace esto primero para verificar compatibilidad
* 4. POST al endpoint WHIP simula el inicio de sesión de OBS
* 5. Verificar que el proxy Caddy /w/* reenvía al livekit-ingress real
*
* Uso:
* node scripts/test-whip-obs-e2e.js
*/
'use strict';
const http = require('http');
const https = require('https');
const crypto = require('crypto');
// ── Config ─────────────────────────────────────────────────────────────────
const LK_API_KEY = process.env.LIVEKIT_API_KEY || 'APIBTqTGxf9htMK';
const LK_API_SECRET = process.env.LIVEKIT_API_SECRET || '0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW';
const LK_WS_URL = process.env.LIVEKIT_WS_URL || 'wss://livekit-server.nextream.sytes.net';
const LK_HTTP_URL = LK_WS_URL.replace(/^wss:\/\//, 'https://').replace(/^ws:\/\//, 'http://');
const UI_BASE_URL = process.env.UI_BASE_URL || 'https://djmaster.nextream.sytes.net';
const ROOM = 'e2e-obs-' + Date.now();
// ── Helpers ────────────────────────────────────────────────────────────────
let failures = 0;
const ok = (msg) => console.log(`${msg}`);
const fail = (msg) => { console.error(`${msg}`); failures++; };
const info = (msg) => console.log(` ${msg}`);
const section = (t) => console.log(`\n━━━━ ${t} ━━━━`);
function request(url, { method = 'GET', headers = {}, body } = {}) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const lib = u.protocol === 'https:' ? https : http;
const raw = body ? (typeof body === 'string' ? body : JSON.stringify(body)) : undefined;
const opts = {
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + (u.search || ''),
method,
headers: {
...headers,
...(raw ? { 'Content-Length': Buffer.byteLength(raw) } : {}),
},
rejectUnauthorized: false,
timeout: 10000,
};
const req = lib.request(opts, (res) => {
let buf = '';
res.on('data', (c) => (buf += c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: buf }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
if (raw) req.write(raw);
req.end();
});
}
// Genera JWT HS256 con node:crypto (solo para el script Node — no para el browser)
function buildToken() {
const b64url = (o) =>
Buffer.from(JSON.stringify(o))
.toString('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const now = Math.floor(Date.now() / 1000);
const header = b64url({ alg: 'HS256', typ: 'JWT' });
const payload = b64url({
iss: LK_API_KEY, sub: 'e2e-test',
iat: now, exp: now + 3600,
video: { roomCreate: true, roomList: true, roomAdmin: true, ingressAdmin: true },
});
const sig = crypto
.createHmac('sha256', LK_API_SECRET)
.update(`${header}.${payload}`)
.digest('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
return `${header}.${payload}.${sig}`;
}
// ── Test 1: Crear Ingress via Twirp (mismo path que el browser) ────────────
async function testCreateIngress() {
section('Test 1 — Crear Ingress (Twirp HTTP API)');
info(`LiveKit Server: ${LK_HTTP_URL}`);
info(`Room: ${ROOM}`);
const token = buildToken();
let ingress;
try {
const r = await request(`${LK_HTTP_URL}/twirp/livekit.Ingress/CreateIngress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: { inputType: 1, name: 'e2e-obs', roomName: ROOM,
participantIdentity: 'obs-whip', participantName: 'OBS' },
});
if (r.status !== 200) {
fail(`Twirp CreateIngress → HTTP ${r.status}: ${r.body.slice(0, 200)}`);
if (r.status === 401) info('→ JWT inválido o credenciales incorrectas');
return null;
}
ingress = JSON.parse(r.body);
ok(`Ingress creado → ingress_id: ${ingress.ingress_id}`);
ok(`stream_key: ${ingress.stream_key}`);
ok(`url interna reportada: ${ingress.url}`);
} catch (err) {
fail(`Twirp request falló: ${err.message}`);
return null;
}
return ingress;
}
// ── Test 2: Construir URL pública ──────────────────────────────────────────
function testBuildUrl(ingress) {
section('Test 2 — Construir URL pública WHIP');
if (!ingress) { fail('Sin ingress (test anterior falló)'); return null; }
const whipUrl = `${UI_BASE_URL}/w/${ingress.stream_key}`;
info(`UI_BASE_URL: ${UI_BASE_URL}`);
info(`stream_key: ${ingress.stream_key}`);
ok(`WHIP URL → ${whipUrl}`);
try {
const u = new URL(whipUrl);
ok(`URL válida (protocol: ${u.protocol}, host: ${u.hostname})`);
if (whipUrl.includes('192.168.') || whipUrl.includes('localhost')) {
fail('La URL contiene IP/host privado — OBS externo NO puede conectar');
} else {
ok('URL no contiene IPs privadas');
}
if (u.pathname.endsWith(ingress.stream_key)) {
ok(`Pathname termina en stream_key (/w/${ingress.stream_key})`);
} else {
fail(`Pathname inesperado: ${u.pathname}`);
}
} catch {
fail(`URL malformada: ${whipUrl}`);
return null;
}
return whipUrl;
}
// ── Test 3: OPTIONS al endpoint WHIP (OBS discovery) ──────────────────────
async function testWhipOptions(whipUrl) {
section('Test 3 — OPTIONS al endpoint WHIP (OBS discovery)');
if (!whipUrl) { fail('Sin URL (test anterior falló)'); return; }
info(`URL: ${whipUrl}`);
try {
const r = await request(whipUrl, {
method: 'OPTIONS',
headers: {
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type',
Origin: UI_BASE_URL,
},
});
info(`HTTP ${r.status}`);
info(`Allow: ${r.headers['allow'] || r.headers['Access-Control-Allow-Methods'] || '(no Allow header)'}`);
info(`Content-Type: ${r.headers['content-type'] || '(none)'}`);
// livekit-ingress responde 200 o 204 a OPTIONS
if ([200, 204, 405].includes(r.status)) {
ok(`OPTIONS respondió ${r.status} — el proxy Caddy /w/* está activo`);
} else if (r.status === 404) {
fail(`404 — Caddy no tiene el bloque "handle /w/*" o livekit-ingress no responde`);
} else if (r.status === 502 || r.status === 503) {
fail(`${r.status} — Caddy no puede alcanzar livekit-ingress (${r.body.slice(0,100)})`);
} else {
info(`Respuesta no estándar ${r.status} — puede ser normal si livekit-ingress no implementa OPTIONS`);
}
// CORS check (necesario si el browser llama directamente)
const acao = r.headers['access-control-allow-origin'];
if (acao) {
ok(`CORS: Access-Control-Allow-Origin: ${acao}`);
} else {
info('Sin header CORS (esperado si OBS no usa CORS)');
}
} catch (err) {
fail(`OPTIONS request falló: ${err.message}`);
if (err.message === 'timeout') {
info('→ El host no responde en 10s — ¿está Caddy corriendo? ¿el dominio resuelve?');
}
}
}
// ── Test 4: POST al endpoint WHIP (simula inicio de sesión de OBS) ─────────
async function testWhipPost(whipUrl) {
section('Test 4 — POST al endpoint WHIP (SDP offer simulado)');
if (!whipUrl) { fail('Sin URL (test anterior falló)'); return; }
info(`URL: ${whipUrl}`);
// SDP mínimo válido — suficiente para que livekit-ingress procese la petición
const sdpOffer = [
'v=0',
'o=- 0 0 IN IP4 127.0.0.1',
's=-',
't=0 0',
'a=group:BUNDLE 0',
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'c=IN IP4 0.0.0.0',
'a=sendonly',
'a=rtpmap:96 H264/90000',
'a=mid:0',
].join('\r\n') + '\r\n';
try {
const r = await request(whipUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: sdpOffer,
});
info(`HTTP ${r.status}`);
info(`Content-Type: ${r.headers['content-type'] || '(none)'}`);
info(`Location: ${r.headers['location'] || '(none)'}`);
if (r.status === 201) {
ok('201 Created — OBS puede conectar correctamente ✅');
info(`SDP answer (primeros 200 chars): ${r.body.slice(0, 200)}`);
} else if (r.status === 422 || r.status === 400) {
// Error de SDP esperado con nuestro SDP mínimo — pero confirma que el endpoint existe
ok(`${r.status} — Endpoint WHIP alcanzado (SDP inválido esperado en e2e)`);
info('OBS enviará un SDP real completo — esto es normal en tests');
} else if (r.status === 404) {
fail('404 — La ruta /w/<stream_key> no existe en livekit-ingress');
info('→ ¿El stream_key es válido? ¿El Ingress fue creado con éxito?');
} else if (r.status === 405) {
fail('405 — El endpoint no acepta POST (Caddy file_server interviniendo?)');
} else if (r.status === 502 || r.status === 503) {
fail(`${r.status} — Caddy no alcanza livekit-ingress`);
info('→ Verifica LIVEKIT_INGRESS_HOST en docker-compose.yml');
info('→ Verifica que livekit-ingress esté corriendo en 192.168.1.20:8088');
} else {
info(`Respuesta ${r.status}: ${r.body.slice(0, 200)}`);
}
} catch (err) {
fail(`POST request falló: ${err.message}`);
}
}
// ── Test 5: Limpiar ingress de prueba ──────────────────────────────────────
async function cleanupIngress(ingressId) {
section('Cleanup — Eliminar Ingress de prueba');
if (!ingressId) { info('Sin ingress que limpiar'); return; }
const token = buildToken();
try {
const r = await request(`${LK_HTTP_URL}/twirp/livekit.Ingress/DeleteIngress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: { ingress_id: ingressId },
});
if (r.status === 200) {
ok(`Ingress ${ingressId} eliminado`);
} else {
info(`Delete respondió ${r.status} (no crítico)`);
}
} catch (err) {
info(`No se pudo eliminar: ${err.message}`);
}
}
// ── Main ───────────────────────────────────────────────────────────────────
(async () => {
console.log('╔════════════════════════════════════════════════════╗');
console.log('║ WHIP OBS End-to-End Test Suite ║');
console.log('╚════════════════════════════════════════════════════╝');
console.log(`\n LK Server: ${LK_HTTP_URL}`);
console.log(` UI Base: ${UI_BASE_URL}`);
console.log(` API Key: ${LK_API_KEY}`);
const ingress = await testCreateIngress();
const whipUrl = testBuildUrl(ingress);
await testWhipOptions(whipUrl);
await testWhipPost(whipUrl);
await cleanupIngress(ingress?.ingress_id);
console.log('\n' + '─'.repeat(54));
if (failures === 0) {
console.log(' ✅ Todos los tests pasaron');
} else {
console.log(`${failures} test(s) fallaron`);
}
console.log('─'.repeat(54) + '\n');
process.exit(failures > 0 ? 1 : 0);
})();

58
scripts/test-whip-post.js Normal file
View File

@ -0,0 +1,58 @@
'use strict';
const https = require('https');
const { IngressClient, IngressInput } = require('/home/xesar/WebstormProjects/restreamer-ui-v2/node_modules/livekit-server-sdk');
const LK_API_KEY = 'APIBTqTGxf9htMK';
const LK_API_SECRET = '0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW';
const LK_HTTP_URL = 'https://livekit-server.nextream.sytes.net';
const CADDY_HOST = 'djmaster.nextream.sytes.net';
const sdp = [
'v=0', 'o=- 0 0 IN IP4 127.0.0.1', 's=-', 't=0 0',
'm=video 9 UDP/TLS/RTP/SAVPF 96',
'c=IN IP4 0.0.0.0',
'a=sendonly', 'a=rtpmap:96 H264/90000', 'a=mid:0',
].join('\r\n') + '\r\n';
function post(path, extraHeaders) {
return new Promise((resolve, reject) => {
const req = https.request({
hostname: CADDY_HOST, path, method: 'POST',
headers: {
'Content-Type': 'application/sdp',
'Content-Length': Buffer.byteLength(sdp),
...extraHeaders,
},
rejectUnauthorized: false, timeout: 10000,
}, (r) => {
let d = '';
r.on('data', (c) => (d += c));
r.on('end', () => resolve({ status: r.statusCode, body: d.slice(0, 200) }));
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.write(sdp);
req.end();
});
}
(async () => {
const client = new IngressClient(LK_HTTP_URL, LK_API_KEY, LK_API_SECRET);
const ingress = await client.createIngress(IngressInput.WHIP_INPUT, {
name: 'test-whip-post', roomName: 'test', participantIdentity: 'obs', participantName: 'OBS',
});
console.log('stream_key :', ingress.streamKey);
console.log('ingress.url:', ingress.url);
console.log('\n[A] POST /w/<stream_key> (stream_key in URL path)');
const a = await post('/w/' + ingress.streamKey, {});
console.log(' status:', a.status, '| body:', a.body);
console.log('\n[B] POST /w (Authorization: Bearer <stream_key>)');
const b = await post('/w', { Authorization: 'Bearer ' + ingress.streamKey });
console.log(' status:', b.status, '| body:', b.body);
await client.deleteIngress(ingress.ingressId).catch(() => {});
console.log('\nIngress eliminado');
})().catch((e) => { console.error(e.message); process.exit(1); });

70
scripts/trace_probe.sh Normal file
View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
# Trace probe script for Restreamer Core
# Usage: ./scripts/trace_probe.sh <CHANNEL_ID> <SOURCE_URL>
# Reads REACT_APP_CORE_URL from .env if present. Optionally set API_TOKEN env var.
# load .env if exists (simple parser)
if [ -f .env ]; then
export $(grep -v '^#' .env | xargs)
fi
CORE_URL=${REACT_APP_CORE_URL:-http://localhost:8080}
API_TOKEN=${API_TOKEN:-}
if [ "$#" -lt 2 ]; then
echo "Usage: $0 <CHANNEL_ID> <SOURCE_URL>"
echo "Example: $0 channel1 http://example.com/stream.m3u8"
exit 2
fi
CHANNEL_ID=$1
SOURCE_URL=$2
PROBE_ID="${CHANNEL_ID}_probe_$(date +%s)"
# Build JSON payload for the probe process
read -r -d '' PAYLOAD <<EOF
{
"type": "ffmpeg",
"id": "${PROBE_ID}",
"reference": "${CHANNEL_ID}",
"input": [
{
"id": "input_0",
"address": "${SOURCE_URL}",
"options": ["-fflags", "+genpts", "-thread_queue_size", "512", "-probesize", "5000000", "-analyzeduration", "20000000", "-re"]
}
],
"output": [
{
"id": "output_0",
"address": "-",
"options": ["-dn", "-sn", "-codec", "copy", "-f", "null"]
}
],
"options": [],
"autostart": false,
"reconnect": false
}
EOF
AUTH_HEADER=()
if [ -n "$API_TOKEN" ]; then
AUTH_HEADER+=( -H "Authorization: Bearer ${API_TOKEN}" )
fi
echo "Core URL: ${CORE_URL}"
echo "Probe ID: ${PROBE_ID}"
echo "1) Creating probe process..."
curl -s -X POST "${CORE_URL}/api/v3/process" -H "Content-Type: application/json" "${AUTH_HEADER[@]}" -d "$PAYLOAD" | jq '.' || true
echo "\n2) Running probe (this may take a few seconds)..."
# Probe endpoint returns JSON result when finished
curl -s "${CORE_URL}/api/v3/process/${PROBE_ID}/probe" "${AUTH_HEADER[@]}" | jq '.' || true
echo "\n3) Deleting probe process..."
curl -s -X DELETE "${CORE_URL}/api/v3/process/${PROBE_ID}" "${AUTH_HEADER[@]}" | jq '.' || true
echo "\nDone. Check Core logs or /api/v3/process/<id>/report if you need more details."

View File

@ -1,5 +1,21 @@
'use strict';
// Carga .env desde la raíz del proyecto (../../.env relativo a server/)
// No sobreescribe vars ya definidas en el entorno (docker-compose tiene prioridad).
const dotenvPath = require('path').resolve(__dirname, '../.env');
if (require('fs').existsSync(dotenvPath)) {
const lines = require('fs').readFileSync(dotenvPath, 'utf8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx < 1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const val = trimmed.slice(eqIdx + 1).trim().replace(/^"|"$/g, '').replace(/^'|'$/g, '');
if (!(key in process.env)) process.env[key] = val;
}
}
const express = require('express');
const cors = require('cors');
const path = require('path');
@ -10,7 +26,7 @@ const crypto = require('crypto');
const { spawn } = require('child_process');
const WebSocket = require('ws');
const { WebSocketServer } = require('ws');
const { AccessToken, RoomServiceClient } = require('livekit-server-sdk');
const { AccessToken, RoomServiceClient, IngressClient, IngressInput } = require('livekit-server-sdk');
const PORT = parseInt(process.env.FB_SERVER_PORT || '3002', 10);
const DATA_DIR = process.env.FB_DATA_DIR
@ -32,6 +48,14 @@ const RTMP_HOST = process.env.RTMP_HOST || '127.0.0.1';
const RTMP_PORT = process.env.RTMP_PORT || '1935';
const RTMP_APP = process.env.RTMP_APP || 'live';
// ── LiveKit Ingress config ─────────────────────────────────────────────────────
// URL interna del servicio livekit-ingress (solo accesible desde el servidor).
// OBS nunca la ve — la URL pública es https://<ui-domain>/w/<streamKey>
const LK_INGRESS_INTERNAL_URL = (process.env.LIVEKIT_INGRESS_INTERNAL_URL || '').replace(/\/+$/, '');
// URL pública del UI (para construir la WHIP URL que ve OBS).
// Si está vacío se auto-detecta del Host header en cada request.
const UI_BASE_URL = (process.env.UI_BASE_URL || '').replace(/\/+$/, '');
// ─────────────────────────────────────────────────────────────────────────────
// Schema unificado de config.json
// ─────────────────────────────────────────────────────────────────────────────
@ -344,6 +368,64 @@ app.get('/health', (_, res) => {
res.json({ ok: true, config: CFG_PATH, port: PORT, ts: new Date().toISOString() });
});
// ═══════════════════════════════════════════════════════════════════════════════
// WHIP INGRESS
// Genera una sesión LiveKit Ingress (WHIP_INPUT) y devuelve al browser
// la URL pública en el mismo dominio, p.ej.:
// https://djmaster.nextream.sytes.net/w/<streamKey>
// Caddy hace proxy de /w/* hacia el servicio livekit-ingress interno.
// OBS lo usa como si fuera un RTMP push: sin CORS, sin IP privada expuesta.
// ═══════════════════════════════════════════════════════════════════════════════
app.post('/api/whip/info', async (req, res) => {
if (!LK_API_KEY || !LK_API_SECRET) {
return res.status(503).json({ error: 'LiveKit API credentials not configured' });
}
if (!LK_WS_URL) {
return res.status(503).json({ error: 'LIVEKIT_WS_URL not configured' });
}
const { room, identity = 'obs_studio', name = 'OBS Studio' } = req.body || {};
if (!room) return res.status(400).json({ error: 'room requerido' });
// URL pública del UI: env var o derivada del Host header
const publicBase = UI_BASE_URL ||
`${req.headers['x-forwarded-proto'] || req.protocol}://${req.headers['x-forwarded-host'] || req.headers.host}`;
try {
// IngressClient debe apuntar al LiveKit SERVER (API REST, puerto 7880).
// NO al servicio livekit-ingress (puerto 8088) — ese es solo para media.
// LK_HTTP_URL ya convierte wss:// → https://
const client = new IngressClient(LK_HTTP_URL, LK_API_KEY, LK_API_SECRET);
const ingress = await client.createIngress(IngressInput.WHIP_INPUT, {
name,
roomName: room,
participantIdentity: identity,
participantName: name,
});
// Construir URL pública: mismo dominio del UI, ruta /w/<streamKey>
// Caddy hace proxy de /w/* → livekit-ingress interno
const whipUrl = `${publicBase}/w/${ingress.streamKey}`;
console.log(`[whip/info] ✅ Ingress creado: room="${room}" → ${whipUrl}`);
res.json({
whipUrl,
streamKey: ingress.streamKey,
ingressId: ingress.ingressId,
identity,
room,
obsInstructions: {
service: 'Custom (WHIP)',
url: whipUrl,
streamKey: '(dejar vacía)',
note: 'OBS → Configuración → Emisión → Servicio: Custom (WHIP) → Servidor: <whipUrl>',
},
});
} catch (err) {
console.error('[whip/info] ERROR:', err.message);
res.status(500).json({ error: err.message });
}
});
// ═════════════════════════════════════════════════════════════════════════════
// LIVEKIT
// ═════════════════════════════════════════════════════════════════════════════

985
server/package-lock.json generated Normal file
View File

@ -0,0 +1,985 @@
{
"name": "restreamer-ui-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "restreamer-ui-server",
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"livekit-server-sdk": "^2.15.0",
"ws": "^8.19.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@livekit/protocol": {
"version": "1.45.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.0.tgz",
"integrity": "sha512-z22Ej7RRBFm5uVZpU7kBHOdDwZV6Hz+1crCOrse2g7yx8TcHXG0bKnOKwyN/meD233nEDlU2IHNCoT8Vq8lvtg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz",
"integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/camelcase-keys": {
"version": "9.1.3",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz",
"integrity": "sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==",
"license": "MIT",
"dependencies": {
"camelcase": "^8.0.0",
"map-obj": "5.0.0",
"quick-lru": "^6.1.1",
"type-fest": "^4.3.2"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/livekit-server-sdk": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.15.0.tgz",
"integrity": "sha512-HmzjWnwEwwShu8yUf7VGFXdc+BuMJR5pnIY4qsdlhqI9d9wDgq+4cdTEHg0NEBaiGnc6PCOBiaTYgmIyVJ0S9w==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.1",
"@livekit/protocol": "^1.43.1",
"camelcase-keys": "^9.0.0",
"jose": "^5.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/map-obj": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz",
"integrity": "sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==",
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-lru": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz",
"integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -13,24 +13,33 @@ const fs = require('fs');
const CORE_TARGET = process.env.REACT_APP_CORE_URL || 'http://localhost:8080';
const YTDLP_TARGET = process.env.REACT_APP_YTDLP_URL || 'http://localhost:8282';
const FB_SERVER_TARGET = process.env.REACT_APP_FB_SERVER_URL || 'http://localhost:3002';
// Dirección LOCAL del servidor egress/whip para el proxy de desarrollo.
// DISTINTO de REACT_APP_WHIP_SERVER_URL (que es la URL pública para el frontend).
// En dev: http://localhost:3005. En prod Caddy proxea directamente, esto no se usa.
const WHIP_SERVER_TARGET = process.env.WHIP_API_TARGET || 'http://localhost:3005';
// WHIP ingest directo a livekit-ingress (en dev OBS no puede acceder via CRA, pero lo mapeamos)
const LIVEKIT_INGRESS_TARGET = process.env.LIVEKIT_INGRESS_INTERNAL_URL || 'http://192.168.1.20:8088';
console.log('\n[setupProxy] ─────────────────────────────────────');
console.log(`[setupProxy] Core → ${CORE_TARGET}`);
console.log(`[setupProxy] yt-dlp → ${YTDLP_TARGET}`);
console.log(`[setupProxy] fb-server → ${FB_SERVER_TARGET}`);
console.log(`[setupProxy] whip/egress → ${WHIP_SERVER_TARGET}`);
console.log('[setupProxy] ─────────────────────────────────────\n');
let coreUrl;
let coreTarget = CORE_TARGET;
try {
coreUrl = new URL(CORE_TARGET);
} catch (e) {
console.error(`[setupProxy] Invalid REACT_APP_CORE_URL: "${CORE_TARGET}"`);
coreUrl = new URL('http://localhost:8080');
console.error(`[setupProxy] Invalid REACT_APP_CORE_URL: "${CORE_TARGET}" — falling back to http://localhost:8080`);
coreTarget = 'http://localhost:8080';
coreUrl = new URL(coreTarget);
}
// Shared proxy instance for all Core paths (/api, /memfs, /diskfs)
const coreProxy = createProxyMiddleware({
target: CORE_TARGET,
target: coreTarget,
changeOrigin: true,
secure: false,
ws: false,
@ -143,6 +152,64 @@ module.exports = function (app) {
}
});
// WHIP Ingress API: /api/whip/* → egress server
// En dev: http://localhost:3005 (egress server/index.js)
// En prod: Caddy proxea /api/whip/* → Node:3002 (restreamer-ui server/index.js)
app.use(
'/api/whip',
createProxyMiddleware({
target: WHIP_SERVER_TARGET,
changeOrigin: true,
secure: false,
onError: (err, req, res) => {
console.error(`[setupProxy] whip proxy error: ${err.code}${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Egress server unavailable', message: err.message }));
}
},
}),
);
// WHEP relay: /whep/* → egress server (Core hace pull del stream desde aquí)
// En dev: http://localhost:3005/whep/*
// En prod: Caddy proxea /whep/* → EGRESS_HOST (llmchats-whep.zuqtxy.easypanel.host)
app.use(
'/whep',
createProxyMiddleware({
target: WHIP_SERVER_TARGET,
changeOrigin: true,
secure: false,
onError: (err, req, res) => {
console.error(`[setupProxy] whep proxy error: ${err.code}${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Egress server unavailable', message: err.message }));
}
},
}),
);
// WHIP ingest: /w/* → livekit-ingress directamente
// En dev apunta a la IP interna del livekit-ingress (192.168.1.20:8088).
// En prod: Caddy proxea /w/* → LIVEKIT_INGRESS_HOST.
// OBS hace: POST https://<host>/w/<stream_key> con Content-Type: application/sdp
app.use(
'/w',
createProxyMiddleware({
target: LIVEKIT_INGRESS_TARGET,
changeOrigin: true,
secure: false,
onError: (err, req, res) => {
console.error(`[setupProxy] livekit-ingress proxy error: ${err.code}${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'LiveKit Ingress unavailable', message: err.message }));
}
},
}),
);
// Facebook OAuth2 callback popup
app.get('/oauth/facebook/callback.html', (req, res) => {
const callbackFile = path.join(__dirname, '..', 'public', 'oauth', 'facebook', 'callback.html');

View File

@ -245,6 +245,24 @@ class API {
return await this._GET('/v3/config/reload');
}
async WhipStreams() {
return await this._GET('/v3/whip', {
expect: 'json',
});
}
async WhipUrl() {
return await this._GET('/v3/whip/url', {
expect: 'json',
});
}
async WhipStreamUrl(name) {
return await this._GET('/v3/whip/' + encodeURIComponent(name) + '/url', {
expect: 'json',
});
}
async Log() {
return await this._GET('/v3/log', {
expect: 'json',

View File

@ -739,6 +739,12 @@ class Restreamer {
local: 'localhost',
credentials: '',
},
whip: {
enabled: false,
host: '',
local: 'localhost',
token: '',
},
},
},
http: {
@ -904,6 +910,18 @@ class Restreamer {
config.source.network.srt.host += ':' + srt_port;
config.source.network.srt.local += ':' + srt_port;
// WHIP
if (val.config.whip) {
config.source.network.whip.enabled = val.config.whip.enable === true;
config.source.network.whip.token = val.config.whip.token || '';
const [whip_host, whip_port] = splitHostPort(val.config.whip.address || ':8555');
const wp = whip_port || '8555';
config.source.network.whip.local = whip_host.length !== 0 ? whip_host : 'localhost';
config.source.network.whip.local += ':' + wp;
config.source.network.whip.host = config.hostname + ':' + wp;
}
// Memfs
config.memfs.auth.enable = val.config.storage.memory.auth.enable;
@ -944,6 +962,50 @@ class Restreamer {
return true;
}
async WhipStreams() {
const [val, err] = await this._call(this.api.WhipStreams);
if (err !== null) {
return [];
}
return Array.isArray(val) ? val : [];
}
async WhipUrl() {
const [val, err] = await this._call(this.api.WhipUrl);
if (err !== null) {
return null;
}
// Allow overriding the public host:port via REACT_APP_WHIP_BASE_URL.
// Useful when the Core reports the wrong public IP (e.g. behind NAT)
// and you want the OBS URL to show a specific host, e.g. a LAN IP.
const override = process.env.REACT_APP_WHIP_BASE_URL;
if (override && val) {
const base = override.replace(/\/$/, '') + '/whip/';
val.base_publish_url = base;
val.example_obs_url = base + '<stream-key>';
}
return val;
}
async WhipStreamUrl(name) {
const [val, err] = await this._call(this.api.WhipStreamUrl, name);
if (err !== null) {
return null;
}
// Same host override as WhipUrl().
const override = process.env.REACT_APP_WHIP_BASE_URL;
if (override && val) {
const base = override.replace(/\/$/, '') + '/whip/';
val.publish_url = base + name;
}
return val;
}
ConfigOverrides(name) {
if (!this.config) {
return false;
@ -2003,24 +2065,101 @@ class Restreamer {
for (let i in inputs) {
const input = inputs[i];
// WHIP inputs use the placeholder {whip:name=<key>} for the actual process,
// but for the temporary probe process we use the direct SDP URL instead.
// Reasons:
// 1. Some Core builds don't expand {whip:...} in probe/temporary processes.
// 2. The probe runs against whatever Core the UI is connected to; that Core
// needs to reach the WHIP SDP endpoint on localhost:<port>.
// 3. -protocol_whitelist is required so FFmpeg can open nested RTP/UDP URLs.
const isWhip =
typeof input.address === 'string' &&
(input.address.startsWith('{whip:') ||
(input.address.includes('/whip/') && input.address.includes('/sdp')));
const options = input.options.map((o) => '' + o);
let address = input.address;
if (isWhip) {
// Expand {whip:name=<key>} → direct SDP URL for the probe process.
// The Core does NOT expand WHIP placeholders in temporary probe processes —
// it only does so for persistent ingest processes. Sending the raw
// placeholder causes FFmpeg to error: "No such file or directory".
const match = input.address.match(/\{whip:name=([^}]+)\}/);
// Use session config local address; fall back to default port 8555.
const whipLocal = this.config?.source?.network?.whip?.local || 'localhost:8555';
if (match) {
address = `http://${whipLocal}/whip/${match[1]}/sdp`;
}
// -protocol_whitelist is required for FFmpeg to open nested RTP/UDP
// sub-protocols inside the HTTP SDP response.
if (!options.includes('-protocol_whitelist')) {
options.unshift('-protocol_whitelist', 'file,crypto,http,rtp,udp,tcp');
}
}
config.input.push({
id: 'input_' + i,
address: input.address,
options: input.options.map((o) => '' + o),
address: address,
options: options,
});
}
await this._deleteProcess(id);
// WHIP probes may return 0×0 video resolution when OBS hasn't sent any
// frames yet (the SDP is available but no RTP data has arrived). Retry
// until we receive either a valid resolution or a fatal error.
// Non-WHIP inputs are probed once and returned immediately.
const isWhipProbe = config.input.some(
(inp) => inp.address.includes('/whip/') && inp.address.includes('/sdp'),
);
const WHIP_MAX_RETRIES = 20; // 20 × 3 s = 60 s maximum wait
const WHIP_RETRY_DELAY_MS = 3000;
let [val, err] = await this._call(this.api.ProcessAdd, config);
if (err !== null) {
return [val, err];
for (let attempt = 0; attempt <= (isWhipProbe ? WHIP_MAX_RETRIES - 1 : 0); attempt++) {
if (attempt > 0) {
// Wait before re-probing so OBS has time to start transmitting.
await new Promise((resolve) => setTimeout(resolve, WHIP_RETRY_DELAY_MS));
}
await this._deleteProcess(id);
let [val, err] = await this._call(this.api.ProcessAdd, config);
if (err !== null) {
return [val, err];
}
[val, err] = await this._call(this.api.ProcessProbe, id);
await this._deleteProcess(id);
if (err !== null) {
return [val, err];
}
// For non-WHIP: always return the first result.
if (!isWhipProbe) {
return [val, err];
}
// For WHIP: 0×0 means OBS is not yet transmitting → retry.
const videoStream = val && val.streams ? val.streams.find((s) => s.type === 'video') : null;
const hasValidVideo = videoStream && (videoStream.width > 0 || videoStream.height > 0);
if (hasValidVideo) {
return [val, err];
}
// No valid video yet — continue to next attempt (or fall through on last).
}
[val, err] = await this._call(this.api.ProcessProbe, id);
await this._deleteProcess(id);
return [val, err];
// All retries exhausted: OBS did not start transmitting in time.
return [
{
streams: [],
log: ['WHIP: No stream detected. Make sure OBS is transmitting to this server.'],
},
null,
];
}
// Probe the ingest stream

View File

@ -10,7 +10,9 @@ import AccordionDetails from '@mui/material/AccordionDetails';
import AccordionSummary from '@mui/material/AccordionSummary';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import Grid from '@mui/material/Grid';
import Icon from '@mui/icons-material/AccountTree';
import MenuItem from '@mui/material/MenuItem';
@ -144,6 +146,14 @@ const initConfig = (initialConfig) => {
...config.hls,
};
config.whip = {
enabled: false,
host: 'localhost:8555',
local: 'localhost:8555',
token: '',
...(initialConfig.whip || {}),
};
return config;
};
@ -218,6 +228,8 @@ const createInputs = (settings, config, skills) => {
name += '.stream';
}
input.address = getLocalSRT(name);
} else if (settings.push.type === 'whip') {
input.address = getLocalWHIP(name);
} else {
input.address = '';
}
@ -279,6 +291,8 @@ const createInputs = (settings, config, skills) => {
}
} else if (settings.push.type === 'srt') {
input.options.push('-analyzeduration', settings.general.analyzeduration);
} else if (settings.push.type === 'whip') {
input.options.push('-analyzeduration', settings.general.analyzeduration);
}
} else {
input.address = addUsernamePassword(input.address, settings.username, settings.password);
@ -499,6 +513,10 @@ const getLocalSRT = (name) => {
return '{srt,name=' + name + ',mode=request}';
};
const getLocalWHIP = (name) => {
return '{whip:name=' + name + '}';
};
const isValidURL = (address) => {
const protocol = getProtocolClass(address);
if (protocol.length === 0) {
@ -993,12 +1011,16 @@ function Push(props) {
<MenuItem value="srt" disabled={!supportsSRT}>
SRT
</MenuItem>
<MenuItem value="whip">
WHIP
</MenuItem>
</Select>
</Grid>
</Grid>
{settings.push.type === 'rtmp' && <PushRTMP {...props} />}
{settings.push.type === 'hls' && <PushHLS {...props} />}
{settings.push.type === 'srt' && <PushSRT {...props} />}
{settings.push.type === 'whip' && <PushWHIP {...props} />}
</React.Fragment>
);
}
@ -1013,6 +1035,149 @@ Push.defaultProps = {
onRefresh: function () {},
};
function PushWHIP(props) {
const classes = useStyles();
const config = initConfig(props.config);
const channelid = config.channelid;
const configWhip = config.whip;
// Live state fetched from Core on every mount — never trust stale session config.
// null = loading, false = disabled/error, object = whip info
const [whipInfo, setWhipInfo] = React.useState(null);
const [obsUrl, setObsUrl] = React.useState('');
const [active, setActive] = React.useState(false);
// ── 1. Load live WHIP state from Core ──────────────────────────────────
React.useEffect(() => {
async function loadWhipInfo() {
// Helper: build whipInfo from the cached session config as a fallback.
// Used when the Core doesn't expose /v3/whip/url (older Core versions)
// or when WhipUrl() fails for any transient reason.
const fromSessionConfig = () =>
configWhip.enabled === true
? { base_publish_url: `http://${configWhip.host}/whip/`, has_token: !!configWhip.token }
: false;
if (props.restreamer?.WhipUrl) {
const info = await props.restreamer.WhipUrl();
// info === null means the endpoint returned an error (e.g. 404 on older
// Core builds that don't expose /v3/whip/url yet). Fall back gracefully.
setWhipInfo(info !== null ? info : fromSessionConfig());
} else {
setWhipInfo(fromSessionConfig());
}
}
loadWhipInfo();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── 2. Build OBS URL once whipInfo is available ────────────────────────
React.useEffect(() => {
if (!whipInfo) {
setObsUrl('');
return;
}
if (props.restreamer?.WhipStreamUrl) {
props.restreamer.WhipStreamUrl(channelid).then((data) => {
if (data?.publish_url) {
setObsUrl(data.publish_url);
} else {
const base = whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
setObsUrl(`${base}${channelid}${configWhip.token ? '?token=' + encodeURIComponent(configWhip.token) : ''}`);
}
});
} else {
const base = whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
setObsUrl(`${base}${channelid}${configWhip.token ? '?token=' + encodeURIComponent(configWhip.token) : ''}`);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [whipInfo, channelid]);
// ── 3. Poll active streams ─────────────────────────────────────────────
const pollWhipStreams = React.useCallback(async () => {
if (!props.restreamer) return;
try {
const streams = await props.restreamer.WhipStreams();
setActive(streams.some((s) => s.name === channelid));
} catch (_) {}
}, [props.restreamer, channelid]);
React.useEffect(() => {
if (!whipInfo || !props.restreamer) return;
pollWhipStreams();
const id = setInterval(pollWhipStreams, 5000);
return () => clearInterval(id);
}, [whipInfo, pollWhipStreams, props.restreamer]);
// Still loading — render nothing to avoid flicker
if (whipInfo === null) {
return null;
}
if (!whipInfo) {
return (
<Grid container alignItems="flex-start" spacing={2} className={classes.gridContainer}>
<Grid item xs={12}>
<Typography>
<Trans>WHIP server is not enabled. Enable it in Settings WHIP Server.</Trans>
</Typography>
</Grid>
</Grid>
);
}
return (
<Grid container alignItems="flex-start" spacing={2} className={classes.gridContainer}>
<Grid item xs={12}>
<Typography>
<Trans>Send stream to this address:</Trans>
</Typography>
</Grid>
<Grid item xs={12}>
<BoxTextarea>
<Textarea rows={1} value={obsUrl} readOnly allowCopy />
</BoxTextarea>
</Grid>
{props.restreamer && (
<Grid item xs={12}>
{active ? (
<Chip
icon={<FiberManualRecordIcon style={{ color: '#27ae60' }} />}
label={<Trans>Live</Trans>}
size="small"
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
/>
) : (
<Chip
icon={<FiberManualRecordIcon style={{ color: 'rgba(255,255,255,0.3)' }} />}
label={<Trans>Waiting for publisher</Trans>}
size="small"
style={{ backgroundColor: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.5)' }}
/>
)}
</Grid>
)}
<AdvancedSettings {...props} />
<Grid item xs={12}>
<FormInlineButton onClick={props.onProbe}>
<Trans>Probe</Trans>
</FormInlineButton>
</Grid>
</Grid>
);
}
PushWHIP.defaultProps = {
knownDevices: [],
settings: {},
config: {},
skills: null,
restreamer: null,
onChange: function (settings) {},
onProbe: function (settings, inputs) {},
onRefresh: function () {},
};
function PushHLS(props) {
const classes = useStyles();
const config = props.config;

View File

@ -1,17 +1,18 @@
/**
* WebRTC Room Source LiveKit
* WebRTC Room Source LiveKit WHIP/WHEP
*
* Architecture (identical to Network Source push-RTMP):
* Dos modos en ambos el Core hace WHEP pull desde el servidor egress:
*
* Browser (webrtc-room page)
* LiveKit SDK (WebRTC)
* LiveKit Server (wss://livekit-server.nextream.sytes.net)
* [future] Node.js relay subscriber FFmpeg
* RTMP push: rtmp://RTMP_HOST/RTMP_APP/<channelid>.stream
* Restreamer Core input: {rtmp,name=<channelid>.stream}
* 1. "whip" OBS / GStreamer / externo publica vía WHIP a LiveKit Ingress.
* OBS POST <UI>/w/<streamKey> (Caddy proxy livekit-ingress)
* LiveKit room egress suscribe
* Core WHEP pull: <WHIP_SERVER_URL>/whep/rooms/<channelId>
*
* The Core process config input address uses the same internal RTMP push
* format as the Network Source in push mode.
* 2. "room" Navegador se une a la sala LiveKit.
* Browser LiveKit room egress suscribe
* Core WHEP pull: <WHIP_SERVER_URL>/whep/rooms/<channelId>
*
* El input del Core es SIEMPRE la URL WHEP del egress nunca RTMP push.
*/
import React from 'react';
@ -24,17 +25,22 @@ import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ScreenShareIcon from '@mui/icons-material/ScreenShare';
import QrCode2Icon from '@mui/icons-material/QrCode2';
import VideocamIcon from '@mui/icons-material/Videocam';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CloudDownloadIcon from '@mui/icons-material/CloudDownload';
import makeStyles from '@mui/styles/makeStyles';
import FormInlineButton from '../../../misc/FormInlineButton';
import BoxText from '../../../misc/BoxText';
const useStyles = makeStyles((theme) => ({
roomUrlBox: {
urlBox: {
display: 'flex',
alignItems: 'center',
gap: '8px',
@ -61,39 +67,54 @@ const initSettings = (initialSettings, config) => {
if (!initialSettings) initialSettings = {};
if (!config) config = {};
// roomId = channelid (same as push.name in Network Source)
const channelId = config.channelid || 'external';
return {
roomId: channelId,
streamKey: channelId, // stream key del WHIP server del Core
showQR: true,
mode: 'whip', // 'whip' | 'room'
...initialSettings,
};
};
// ─── Create FFmpeg inputs ─────────────────────────────────────────────────────
// Identical pattern to Network Source push RTMP:
// getLocalRTMP(name) → '{rtmp,name=' + name + '}'
// where name = <channelid>.stream
// El Core recibe el stream vía WHIP nativo (servidor en :8555).
// Input address: {whip:name=<streamKey>}
// El Core expande el placeholder a http://localhost:<port>/whip/<key>/sdp
// e inyecta automáticamente -protocol_whitelist file,crypto,http,rtp,udp,tcp
const createInputs = (settings, config) => {
if (!config) config = {};
const channelId = config.channelid || settings.roomId || 'external';
// Match Network.js: if name === channelid, append '.stream'
const streamName = channelId.endsWith('.stream') ? channelId : channelId + '.stream';
const streamKey = settings.streamKey || config.channelid || settings.roomId || 'external';
return [
{
address: `{rtmp,name=${streamName}}`,
options: [
'-fflags', '+genpts',
'-analyzeduration', '3000000', // 3s — same as push RTMP in Network.js
'-probesize', '5000000',
'-thread_queue_size','512',
],
// Placeholder nativo del Core — NO usar URL directa.
// El Core inyecta -protocol_whitelist automáticamente.
address: `{whip:name=${streamKey}}`,
options: [],
},
];
};
// ─── URL helpers ──────────────────────────────────────────────────────────────
/** URL pública del servidor egress (WHIP/WHEP).
* Lee: window.__RESTREAMER_CONFIG__.WHIP_SERVER_URL (runtime config)
* o process.env.REACT_APP_WHIP_SERVER_URL (build-time)
*/
function getWhipServerUrl() {
if (window.__RESTREAMER_CONFIG__ && window.__RESTREAMER_CONFIG__.WHIP_SERVER_URL) {
return String(window.__RESTREAMER_CONFIG__.WHIP_SERVER_URL).replace(/\/+$/, '');
}
if (process.env.REACT_APP_WHIP_SERVER_URL) {
return String(process.env.REACT_APP_WHIP_SERVER_URL).replace(/\/+$/, '');
}
// Fallback: mismo origen del UI (útil si egress corre detrás del mismo proxy)
const loc = window.location;
return (loc.origin || `${loc.protocol}//${loc.host}`);
}
// ─── Build room URL ───────────────────────────────────────────────────────────
function buildRoomUrl(settings) {
const base = window.location;
@ -102,6 +123,20 @@ function buildRoomUrl(settings) {
return `${origin}/webrtc-room/?room=${encodeURIComponent(roomId)}`;
}
/** URL WHIP a la que OBS o cualquier publicador WHIP debe enviar su stream. */
function buildWhipUrl(settings) {
const base = getWhipServerUrl();
const roomId = settings.roomId || 'external';
return `${base}/whip/rooms/${encodeURIComponent(roomId)}`;
}
/** URL WHEP que Restreamer Core usa como fuente de entrada (pull). */
function buildWhepUrl(settings) {
const base = getWhipServerUrl();
const roomId = settings.roomId || 'external';
return `${base}/whep/rooms/${encodeURIComponent(roomId)}`;
}
// ─── QR Code via API ──────────────────────────────────────────────────────────
function QRImage({ url }) {
if (!url) return null;
@ -118,31 +153,29 @@ function Source(props) {
const classes = useStyles();
const settings = initSettings(props.settings, props.config);
const [copied, setCopied] = React.useState(false);
const [lkStatus, setLkStatus] = React.useState(null); // null=checking, true=ok, false=err
const [showQR, setShowQR] = React.useState(settings.showQR !== false);
const [copied, setCopied] = React.useState(false);
const [showQR, setShowQR] = React.useState(settings.showQR !== false);
const mode = settings.mode || 'whip';
const roomUrl = buildRoomUrl(settings);
// ── Check LiveKit config ──
React.useEffect(() => {
fetch('/livekit/config', { signal: AbortSignal.timeout(3000) })
.then(r => setLkStatus(r.ok))
.catch(() => setLkStatus(false));
}, []);
const whepUrl = buildWhepUrl(settings);
const handleChange = (key) => (e) => {
props.onChange({ ...settings, [key]: e.target.value });
};
const handleModeChange = (_e, newMode) => {
if (newMode) props.onChange({ ...settings, mode: newMode });
};
const handleProbe = () => {
props.onProbe(settings, createInputs(settings, props.config));
};
const handleCopy = () => {
const handleCopy = (text) => () => {
if (navigator.clipboard) {
navigator.clipboard.writeText(roomUrl).then(() => {
setCopied(true);
navigator.clipboard.writeText(text).then(() => {
setCopied(text);
setTimeout(() => setCopied(false), 2000);
});
}
@ -159,127 +192,123 @@ function Source(props) {
);
};
// Stream name that will be pushed to Restreamer Core
const streamName = (settings.roomId || 'external').replace(/\.stream$/, '') + '.stream';
return (
<Grid container alignItems="flex-start" spacing={2} style={{ marginTop: '0.5em' }}>
{/* ── Info ── */}
{/* ── Modo ── */}
<Grid item xs={12}>
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.5)', display: 'block', marginBottom: '6px' }}>
<Trans>Cómo llega el stream a LiveKit</Trans>
</Typography>
<ToggleButtonGroup value={mode} exclusive onChange={handleModeChange} size="small">
<ToggleButton value="whip">
<CloudUploadIcon style={{ fontSize: '1rem', marginRight: '4px' }} />
<Trans>WHIP / OBS</Trans>
</ToggleButton>
<ToggleButton value="room">
<ScreenShareIcon style={{ fontSize: '1rem', marginRight: '4px' }} />
<Trans>Sala LiveKit</Trans>
</ToggleButton>
</ToggleButtonGroup>
</Grid>
{/* ── Info: el Core siempre usa WHEP pull ── */}
<Grid item xs={12}>
<div className={classes.infoBox}>
<Typography variant="body2" style={{ marginBottom: '6px' }}>
<ScreenShareIcon style={{ fontSize: '1rem', verticalAlign: 'middle', marginRight: '4px' }} />
<Trans>
Un cliente (navegador) se conecta a la sala LiveKit para compartir su
pantalla o cámara. El video llega al Core vía RTMP push.
</Trans>
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.7)', marginBottom: '6px' }}>
{mode === 'whip' ? (
<><CloudUploadIcon style={{ fontSize: '1rem', verticalAlign: 'middle', marginRight: '4px' }} />
<Trans>OBS u otro cliente publica vía WHIP a LiveKit Ingress. La URL se genera en el asistente de configuración.</Trans></>
) : (
<><ScreenShareIcon style={{ fontSize: '1rem', verticalAlign: 'middle', marginRight: '4px' }} />
<Trans>Un navegador se une a la sala LiveKit para compartir pantalla o cámara.</Trans></>
)}
</Typography>
<div>
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.45)', fontSize: '0.8rem' }}>
<CloudDownloadIcon style={{ fontSize: '0.9rem', verticalAlign: 'middle', marginRight: '4px' }} />
<Trans>En ambos modos el Core recibe el stream por WHEP pull desde el servidor egress.</Trans>
</Typography>
<div style={{ marginTop: '8px' }}>
<Chip size="small" label="WHEP pull" className={classes.chip}
style={{ backgroundColor: '#27ae60', color: '#fff' }} />
<Chip size="small" label="LiveKit" className={classes.chip}
style={{ backgroundColor: '#4f8ef7', color: '#fff' }} />
<Chip size="small" label="Screen share" className={classes.chip}
style={{ backgroundColor: '#7c5ce4', color: '#fff' }} />
<Chip size="small" label="Camera" className={classes.chip}
style={{ backgroundColor: '#27ae60', color: '#fff' }} />
{mode === 'whip' && <Chip size="small" label="OBS / WHIP Ingress" className={classes.chip}
style={{ backgroundColor: '#e67e22', color: '#fff' }} />}
{mode === 'room' && <Chip size="small" label="Navegador" className={classes.chip}
style={{ backgroundColor: '#8e44ad', color: '#fff' }} />}
</div>
</div>
</Grid>
{/* ── LiveKit status ── */}
{lkStatus === false && (
<Grid item xs={12}>
<BoxText color="dark">
<Typography variant="body2" style={{ color: '#e74c3c' }}>
<Trans>
El servidor LiveKit no está disponible. Verifica LIVEKIT_API_KEY,
LIVEKIT_API_SECRET y LIVEKIT_WS_URL en el docker-compose.
</Trans>
</Typography>
</BoxText>
</Grid>
)}
{lkStatus === true && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#2ecc71', fontSize: '0.8rem' }}>
<Trans>LiveKit configurado correctamente</Trans>
</Typography>
</Grid>
)}
<Grid item xs={12}><Divider /></Grid>
{/* ── Room ID (= channelid) ── */}
{/* ── Room ID ── */}
<Grid item xs={12}>
<TextField
variant="outlined" fullWidth
label={i18n._(t`Room ID`)}
value={settings.roomId}
onChange={handleChange('roomId')}
helperText={i18n._(t`Identificador de la sala LiveKit. Por defecto usa el ID del canal (${streamName}).`)}
helperText={i18n._(t`ID de la sala LiveKit. Debe coincidir con el canal del egress.`)}
/>
</Grid>
<Grid item xs={12}><Divider /></Grid>
{/* ── URL de la sala (solo modo room) ── */}
{mode === 'room' && (
<>
<Grid item xs={12}><Divider /></Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" style={{ marginBottom: '6px' }}>
<Trans>URL de la sala para el presentador</Trans>
</Typography>
<div className={classes.urlBox}>
<TextField
variant="outlined" fullWidth size="small"
value={roomUrl}
InputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: '0.78rem' } }}
/>
<Button variant="outlined" size="small" onClick={handleCopy(roomUrl)}
startIcon={<ContentCopyIcon />} style={{ whiteSpace: 'nowrap', minWidth: '80px' }}>
{copied === roomUrl ? <Trans>¡Copiado!</Trans> : <Trans>Copiar</Trans>}
</Button>
<Button variant="outlined" size="small" onClick={handleOpenRoom}
startIcon={<OpenInNewIcon />} style={{ whiteSpace: 'nowrap' }}>
<Trans>Abrir</Trans>
</Button>
</div>
</Grid>
<Grid item xs={12}>
<Button variant="text" size="small" startIcon={<QrCode2Icon />}
onClick={() => setShowQR(v => !v)}
style={{ color: 'rgba(255,255,255,0.6)' }}>
{showQR ? <Trans>Ocultar QR</Trans> : <Trans>Mostrar QR</Trans>}
</Button>
{showQR && <div className={classes.qrContainer}><QRImage url={roomUrl} /></div>}
</Grid>
</>
)}
{/* ── Room URL ── */}
{/* ── WHEP input que usará el Core (siempre visible) ── */}
<Grid item xs={12}><Divider /></Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" style={{ marginBottom: '6px' }}>
<Trans>URL de la sala para el presentador</Trans>
<Trans>Input del Core (WHEP pull egress)</Trans>
</Typography>
<div className={classes.roomUrlBox}>
<div className={classes.urlBox}>
<TextField
variant="outlined" fullWidth size="small"
value={roomUrl}
value={whepUrl}
InputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: '0.78rem' } }}
helperText={<Trans>El Core se conecta aquí para recibir el stream vía WHEP.</Trans>}
/>
<Button variant="outlined" size="small" onClick={handleCopy}
<Button variant="outlined" size="small" onClick={handleCopy(whepUrl)}
startIcon={<ContentCopyIcon />} style={{ whiteSpace: 'nowrap', minWidth: '80px' }}>
{copied ? <Trans>¡Copiado!</Trans> : <Trans>Copiar</Trans>}
</Button>
<Button variant="outlined" size="small" onClick={handleOpenRoom}
startIcon={<OpenInNewIcon />} style={{ whiteSpace: 'nowrap' }}>
<Trans>Abrir</Trans>
{copied === whepUrl ? <Trans>¡Copiado!</Trans> : <Trans>Copiar</Trans>}
</Button>
</div>
</Grid>
{/* ── QR ── */}
<Grid item xs={12}>
<Button variant="text" size="small" startIcon={<QrCode2Icon />}
onClick={() => setShowQR(v => !v)}
style={{ color: 'rgba(255,255,255,0.6)', marginBottom: '4px' }}>
{showQR ? <Trans>Ocultar QR</Trans> : <Trans>Mostrar QR</Trans>}
</Button>
{showQR && <div className={classes.qrContainer}><QRImage url={roomUrl} /></div>}
</Grid>
<Grid item xs={12}><Divider /></Grid>
{/* ── RTMP stream info ── */}
<Grid item xs={12}>
<Typography variant="subtitle2" style={{ marginBottom: '4px' }}>
<Trans>Configuración del proceso</Trans>
</Typography>
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.5)', fontFamily: 'monospace', fontSize: '0.78rem' }}>
input: {`{rtmp,name=${streamName}}`}
</Typography>
</Grid>
<Grid item xs={12}><Divider /></Grid>
{/* ── Instructions ── */}
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom><Trans>Cómo usar</Trans>:</Typography>
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.6)', lineHeight: 1.7 }}>
1. <Trans>Haz clic en</Trans> <strong><Trans>Probar</Trans></strong> <Trans>para registrar el input RTMP push</Trans>.<br />
2. <Trans>Guarda el canal y activa el proceso</Trans>.<br />
3. <Trans>Abre la sala (botón</Trans> <strong><Trans>Abrir</Trans></strong>) <Trans>y elige Pantalla o Cámara</Trans>.<br />
4. <Trans>Pulsa</Trans> <strong>🚀 Iniciar transmisión</strong> <Trans>en la sala</Trans>.<br />
5. <Trans>El video llega al Core vía RTMP push interno</Trans>.
</Typography>
</Grid>
{/* ── Probe ── */}
<Grid item xs={12}>
<FormInlineButton onClick={handleProbe}><Trans>Probar</Trans></FormInlineButton>
@ -304,7 +333,7 @@ const name = <Trans>WebRTC Room</Trans>;
const capabilities = ['video'];
const ffversion = '^4.1.0 || ^5.0.0 || ^6.1.0';
const func = { initSettings, createInputs };
const func = { initSettings, createInputs, buildWhipUrl, buildWhepUrl, getWhipServerUrl };
export { id, name, capabilities, ffversion, SourceIcon as icon, Source as component, func };

View File

@ -9,7 +9,7 @@ import * as VideoLoop from './VideoLoop';
import * as AudioLoop from './AudioLoop';
import * as VirtualAudio from './VirtualAudio';
import * as VirtualVideo from './VirtualVideo';
import * as WebRTCRoom from './WebRTCRoom';
// WebRTCRoom removed — WHIP is handled via Network source (push.type = 'whip')
class Registry {
constructor() {
@ -51,6 +51,6 @@ registry.Register(NoAudio);
registry.Register(VideoAudio);
registry.Register(VideoLoop);
registry.Register(AudioLoop);
registry.Register(WebRTCRoom);
export default registry;

View File

@ -18,8 +18,8 @@ export default function Source(props) {
<Typography>
<Trans>
Select whether you pull the stream from a <strong>network source</strong> (such as a network camera), the{' '}
<strong>internal RTMP server</strong> (e.g., OBS streams to the Restreamer), or use a{' '}
<strong>WebRTC Room</strong> to stream directly from a browser (screen share or camera).
<strong>internal RTMP server</strong> (e.g., OBS streams to the Restreamer), the{' '}
<strong>internal SRT server</strong>, or the <strong>WHIP server</strong> (OBS 30+ WebRTC ingestion).
</Trans>
</Typography>
</Grid>

View File

@ -0,0 +1,189 @@
import React from 'react';
import { Trans } from '@lingui/macro';
import Chip from '@mui/material/Chip';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import Grid from '@mui/material/Grid';
import Icon from '@mui/icons-material/CloudUpload';
import Typography from '@mui/material/Typography';
import BoxTextarea from '../../../../misc/BoxTextarea';
import Textarea from '../../../../misc/Textarea';
function Source(props) {
const channelid = props.config?.channelid || 'external';
const configWhip = props.config?.whip || {};
// Live state fetched from Core on every mount — never trust the cached session config.
// null = still loading
// false = WHIP disabled or Core returned an error
// object = { base_publish_url, has_token, ... } (from GET /v3/whip/url)
const [whipInfo, setWhipInfo] = React.useState(null);
const [obsUrl, setObsUrl] = React.useState('');
const [bearerToken, setBearerToken] = React.useState('');
const [active, setActive] = React.useState(false);
// ── 1. Load live WHIP state from Core ──────────────────────────────────
const loadWhipInfo = React.useCallback(async () => {
// Helper: build whipInfo from the cached session config as a fallback.
// Used when the Core doesn't expose /v3/whip/url (older Core versions)
// or when WhipUrl() fails for any transient reason.
const fromSessionConfig = () =>
configWhip.enabled === true
? { base_publish_url: `http://${configWhip.host}/whip/`, has_token: !!configWhip.token }
: false;
if (props.restreamer?.WhipUrl) {
const info = await props.restreamer.WhipUrl();
// info === null means the endpoint returned an error (e.g. 404 on older
// Core builds that don't expose /v3/whip/url yet). Fall back gracefully.
setWhipInfo(info !== null ? info : fromSessionConfig());
} else {
// No authenticated API available — use cached session config directly.
setWhipInfo(fromSessionConfig());
}
}, [props.restreamer, configWhip.enabled, configWhip.host, configWhip.token]);
React.useEffect(() => {
loadWhipInfo();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── 2. Build OBS URL once whipInfo is available ────────────────────────
React.useEffect(() => {
if (!whipInfo) {
setObsUrl('');
setBearerToken('');
return;
}
// OBS WHIP has two separate fields:
// • Server → the WHIP endpoint URL (NO token in URL)
// • Bearer Token → the token value (sent as Authorization: Bearer header)
if (props.restreamer?.WhipStreamUrl) {
props.restreamer.WhipStreamUrl(channelid).then((data) => {
if (data?.publish_url) {
// Strip any ?token=... query param — token goes in Bearer Token field
const clean = data.publish_url.split('?')[0];
setObsUrl(clean);
} else {
const base = whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
setObsUrl(`${base}${channelid}`);
}
setBearerToken(configWhip.token || '');
});
} else {
const base = whipInfo.base_publish_url || `http://${configWhip.host}/whip/`;
setObsUrl(`${base}${channelid}`);
setBearerToken(configWhip.token || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [whipInfo, channelid]);
// ── 3. Poll active streams ─────────────────────────────────────────────
const pollWhipStreams = React.useCallback(async () => {
if (!props.restreamer) return;
try {
const streams = await props.restreamer.WhipStreams();
setActive(streams.some((s) => s.name === channelid));
} catch (_) {}
}, [props.restreamer, channelid]);
React.useEffect(() => {
if (!whipInfo || !props.restreamer) return;
pollWhipStreams();
const id = setInterval(pollWhipStreams, 5000);
return () => clearInterval(id);
}, [whipInfo, pollWhipStreams, props.restreamer]);
// ── 4. Notify parent whenever enabled state resolves ──────────────────
React.useEffect(() => {
if (whipInfo === null) return; // still loading — don't fire yet
const enabled = !!whipInfo;
const inputs = [{ address: `{whip:name=${channelid}}`, options: [] }];
props.onChange('network', { mode: 'push', push: { type: 'whip', name: channelid } }, inputs, enabled);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [whipInfo, channelid]);
// Still loading — render nothing to avoid flicker
if (whipInfo === null) {
return null;
}
if (!whipInfo) {
return (
<Grid item xs={12}>
<Typography>
<Trans>WHIP server is not enabled. Enable it in Settings WHIP Server.</Trans>
</Typography>
</Grid>
);
}
return (
<React.Fragment>
<Grid item xs={12}>
<Typography>
<Trans>OBS Settings Stream Service: WHIP</Trans>
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" style={{ opacity: 0.7 }}>
<Trans>Server</Trans>
</Typography>
<BoxTextarea>
<Textarea rows={1} value={obsUrl} readOnly allowCopy />
</BoxTextarea>
</Grid>
{bearerToken && (
<Grid item xs={12}>
<Typography variant="caption" style={{ opacity: 0.7 }}>
<Trans>Bearer Token</Trans>
</Typography>
<BoxTextarea>
<Textarea rows={1} value={bearerToken} readOnly allowCopy />
</BoxTextarea>
</Grid>
)}
{props.restreamer && (
<Grid item xs={12}>
{active ? (
<Chip
icon={<FiberManualRecordIcon style={{ color: '#27ae60' }} />}
label={<Trans>Live</Trans>}
size="small"
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
/>
) : (
<Chip
icon={<FiberManualRecordIcon style={{ color: 'rgba(255,255,255,0.3)' }} />}
label={<Trans>Waiting for publisher</Trans>}
size="small"
style={{ backgroundColor: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.5)' }}
/>
)}
</Grid>
)}
</React.Fragment>
);
}
Source.defaultProps = {
knownDevices: [],
settings: {},
config: null,
skills: null,
restreamer: null,
onChange: function (type, settings, inputs, ready) {},
onRefresh: function () {},
};
function SourceIcon(props) {
return <Icon style={{ color: '#FFF' }} {...props} />;
}
const id = 'whip';
const type = 'network';
const name = <Trans>WHIP server</Trans>;
const capabilities = ['audio', 'video'];
export { id, type, name, capabilities, SourceIcon as icon, Source as component };

View File

@ -0,0 +1,249 @@
/**
* E2E integration tests for the WHIP source component.
*
* Verifies:
* 1. The component calls WhipUrl() on mount live state from Core, not stale cache.
* 2. OBS URL is built from WhipUrl() response (base_publish_url + channelid).
* 3. Falls back to props.config.whip when no restreamer is provided.
* 4. "WHIP server is not enabled" is shown when WhipUrl() returns null/false.
* 5. Live / Waiting chip is shown when restreamer.WhipStreams() responds.
* 6. onChange fires with correct FFmpeg placeholder and enabled=true/false.
* 7. After a Core restart, re-mounting the component fetches fresh state.
*/
import React from 'react';
import { render, screen, act } from '../../../../utils/testing';
import '@testing-library/jest-dom';
import { component as Source } from './InternalWHIP';
// ── Shared config fixtures ────────────────────────────────────────────────
const configEnabled = {
channelid: 'my-channel',
whip: {
enabled: true,
host: '192.168.1.15:8555',
local: 'localhost:8555',
token: '',
},
};
const configWithToken = {
channelid: 'my-channel',
whip: {
enabled: true,
host: '192.168.1.15:8555',
local: 'localhost:8555',
token: 'secret-token',
},
};
const configDisabled = {
channelid: 'my-channel',
whip: {
enabled: false,
host: '',
local: 'localhost:8555',
token: '',
},
};
// ── Restreamer mock factory ───────────────────────────────────────────────
function makeRestreamer(activeStreams = [], whipEnabled = true) {
const whipInfoResponse = whipEnabled
? {
base_publish_url: 'http://192.168.1.15:8555/whip/',
base_sdp_url: 'http://localhost:8555/whip/',
has_token: false,
example_obs_url: 'http://192.168.1.15:8555/whip/<stream-key>',
input_address_template: '{whip:name=<stream-key>}',
}
: null;
return {
WhipUrl: jest.fn().mockResolvedValue(whipInfoResponse),
WhipStreamUrl: jest.fn().mockImplementation((name) =>
Promise.resolve({
publish_url: `http://192.168.1.15:8555/whip/${name}`,
sdp_url: `http://localhost:8555/whip/${name}/sdp`,
stream_key: name,
}),
),
WhipStreams: jest.fn().mockResolvedValue(activeStreams),
};
}
// ── Tests ─────────────────────────────────────────────────────────────────
test('whip:url — shows correct OBS URL from WhipStreamUrl (live fetch)', async () => {
const restreamer = makeRestreamer([], true);
const handleChange = jest.fn();
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={handleChange} />);
});
// Flush WhipUrl + WhipStreamUrl promises
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
expect(restreamer.WhipUrl).toHaveBeenCalledTimes(1);
expect(restreamer.WhipStreamUrl).toHaveBeenCalledWith('my-channel');
expect(screen.getByDisplayValue('http://192.168.1.15:8555/whip/my-channel')).toBeInTheDocument();
});
test('whip:url — fallback to props.config when no restreamer provided', async () => {
const handleChange = jest.fn();
await act(async () => {
render(<Source config={configEnabled} onChange={handleChange} />);
});
await act(async () => { await Promise.resolve(); });
// Fallback constructs: base_publish_url (from configWhip) + channelid
expect(screen.getByDisplayValue('http://192.168.1.15:8555/whip/my-channel')).toBeInTheDocument();
});
test('whip:url — shows Bearer Token field separately when token is set (no restreamer)', async () => {
// OBS WHIP has two fields: Server URL (no token in URL) and Bearer Token.
// The token must NOT be appended as ?token=... to the URL.
const handleChange = jest.fn();
await act(async () => {
render(<Source config={configWithToken} onChange={handleChange} />);
});
await act(async () => { await Promise.resolve(); });
// Server URL must NOT contain the token
expect(
screen.getByDisplayValue('http://192.168.1.15:8555/whip/my-channel'),
).toBeInTheDocument();
// Bearer Token must be shown in a separate copy field
expect(screen.getByDisplayValue('secret-token')).toBeInTheDocument();
});
test('whip:disabled — shows disabled message when WhipUrl returns null AND session config is disabled', async () => {
const restreamer = makeRestreamer([], false); // WhipUrl returns null
const handleChange = jest.fn();
await act(async () => {
// configDisabled: session config has enabled=false → fallback shows disabled
render(<Source config={configDisabled} restreamer={restreamer} onChange={handleChange} />);
});
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/WHIP server is not enabled/i)).toBeInTheDocument();
});
test('whip:disabled (fallback) — shows enabled panel when WhipUrl returns null but session config is enabled', async () => {
const restreamer = makeRestreamer([], false); // WhipUrl returns null
const handleChange = jest.fn();
await act(async () => {
// configEnabled: session config has enabled=true → fallback uses it → shows OBS URL
render(<Source config={configEnabled} restreamer={restreamer} onChange={handleChange} />);
});
await act(async () => { await Promise.resolve(); });
// Should show the OBS URL field, not the disabled message
expect(screen.queryByText(/WHIP server is not enabled/i)).not.toBeInTheDocument();
});
test('whip:disabled — shows disabled message when props.config.whip.enabled is false (no restreamer)', async () => {
const handleChange = jest.fn();
await act(async () => {
render(<Source config={configDisabled} onChange={handleChange} />);
});
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/WHIP server is not enabled/i)).toBeInTheDocument();
});
test('whip:onChange — emits correct FFmpeg placeholder and enabled=true after WhipUrl resolves', async () => {
const restreamer = makeRestreamer([], true);
const handleChange = jest.fn();
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={handleChange} />);
});
await act(async () => { await Promise.resolve(); });
expect(handleChange).toHaveBeenCalledWith(
'network',
expect.objectContaining({ mode: 'push', push: { type: 'whip', name: 'my-channel' } }),
[{ address: '{whip:name=my-channel}', options: [] }],
true,
);
});
test('whip:onChange — emits enabled=true (from session config fallback) when WhipUrl returns null but config is enabled', async () => {
const restreamer = makeRestreamer([], false);
const handleChange = jest.fn();
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={handleChange} />);
});
await act(async () => { await Promise.resolve(); });
const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1];
// When WhipUrl fails, session config (enabled=true) is used → enabled=true emitted
expect(lastCall[3]).toBe(true);
});
test('whip:live — shows "Live" chip when stream is active', async () => {
const restreamer = makeRestreamer([{ name: 'my-channel', published_at: '2026-03-14T00:00:00Z' }], true);
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
expect(screen.getByText('Live')).toBeInTheDocument();
expect(restreamer.WhipStreams).toHaveBeenCalledTimes(1);
});
test('whip:waiting — shows "Waiting" chip when no active publisher', async () => {
const restreamer = makeRestreamer([], true); // WhipUrl enabled, no active streams
await act(async () => {
render(<Source config={configEnabled} restreamer={restreamer} onChange={jest.fn()} />);
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
expect(screen.getByText(/Waiting for publisher/i)).toBeInTheDocument();
});
test('whip:no-chip — no chip shown when restreamer prop is absent', async () => {
await act(async () => {
render(<Source config={configEnabled} onChange={jest.fn()} />);
});
await act(async () => { await Promise.resolve(); });
expect(screen.queryByText('Live')).not.toBeInTheDocument();
expect(screen.queryByText(/Waiting for publisher/i)).not.toBeInTheDocument();
});
test('whip:restart-recovery — re-mounting fetches fresh WhipUrl from Core', async () => {
// Simulate: session was initialized with WHIP disabled (stale cache),
// but after Core restart WhipUrl() now returns enabled state.
const configStale = { channelid: 'my-channel', whip: { enabled: false, host: '', local: '', token: '' } };
const restreamer = makeRestreamer([], true); // Core now has WHIP enabled
const handleChange = jest.fn();
await act(async () => {
render(<Source config={configStale} restreamer={restreamer} onChange={handleChange} />);
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
// Even though configStale.whip.enabled === false,
// WhipUrl() returned a valid info object → WHIP is shown as enabled.
expect(restreamer.WhipUrl).toHaveBeenCalledTimes(1);
expect(screen.queryByText(/WHIP server is not enabled/i)).not.toBeInTheDocument();
expect(screen.getByDisplayValue('http://192.168.1.15:8555/whip/my-channel')).toBeInTheDocument();
});

View File

@ -5,57 +5,282 @@ import Grid from '@mui/material/Grid';
import Icon from '@mui/icons-material/ScreenShare';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import RefreshIcon from '@mui/icons-material/Refresh';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
import Chip from '@mui/material/Chip';
import * as S from '../../Sources/WebRTCRoom';
/** Extrae el puerto de una dirección del tipo ":8555" o "host:8555" → "8555" */
function extractPort(address, fallback) {
if (!address) return fallback;
const m = address.match(/:(\d+)$/);
return m ? m[1] : fallback;
}
/** Construye la URL base WHIP a partir del config del Core */
function buildWhipBaseUrl(coreConfig) {
const whip = coreConfig?.whip;
if (!whip?.enable) return '';
const port = extractPort(whip.address, '8555');
const hosts = coreConfig?.host?.name;
const host = Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : window.location.hostname;
return `http://${host}:${port}/whip`;
}
const initSettings = (initialSettings, config) => {
return S.func.initSettings(initialSettings, config);
};
function Source(props) {
const config = { channelid: 'external', ...(props.config || {}) };
const settings = initSettings(props.settings, config);
const config = { channelid: 'external', ...(props.config || {}) };
const settings = initSettings(props.settings, config);
const streamKey = settings.streamKey || config.channelid || 'external';
const [mode, setMode] = React.useState(settings.mode || 'whip');
const [copied, setCopied] = React.useState(false);
// Core WHIP config
const [coreConfig, setCoreConfig] = React.useState(null);
const [configLoad, setConfigLoad] = React.useState(false);
const [configErr, setConfigErr] = React.useState('');
// Active streams polling
const [active, setActive] = React.useState(false);
const [localKey, setLocalKey] = React.useState(streamKey);
const roomId = settings.roomId || config.channelid || 'external';
const origin = window.location.origin;
const host = settings.relayHost || window.location.host;
const roomUrl = `${origin}/webrtc-room/?room=${encodeURIComponent(roomId)}&host=${encodeURIComponent(host)}`;
const roomUrl = `${origin}/webrtc-room/?room=${encodeURIComponent(localKey)}&host=${encodeURIComponent(host)}`;
const handleChange = (newSettings) => {
newSettings = newSettings || settings;
const inputs = S.func.createInputs(newSettings);
props.onChange(S.id, newSettings, inputs, true);
const whipBase = coreConfig ? buildWhipBaseUrl(coreConfig) : '';
const token = coreConfig?.whip?.token || '';
const whipEnabled = coreConfig?.whip?.enable === true;
const whipUrl = whipBase
? `${whipBase}/${localKey}${token ? '?token=' + token : ''}`
: '';
const makeInputs = () => {
const s = { ...settings, streamKey: localKey, mode };
return S.func.createInputs(s, config);
};
const handleChange = (m, key) => {
const k = key !== undefined ? key : localKey;
props.onChange(S.id, { ...settings, streamKey: k, mode: m }, makeInputs(), false);
};
// Carga la config del Core (GET /api/v3/config)
const fetchCoreConfig = React.useCallback(async () => {
setConfigLoad(true);
setConfigErr('');
try {
const res = await fetch('/api/v3/config');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setCoreConfig(data);
} catch (err) {
setConfigErr(err.message);
} finally {
setConfigLoad(false);
}
}, []);
// Poll GET /api/v3/whip para ver si el stream está activo
const pollWhipStreams = React.useCallback(async () => {
try {
const res = await fetch('/api/v3/whip');
if (!res.ok) return;
const streams = await res.json();
setActive(Array.isArray(streams) && streams.some((s) => s.name === localKey));
} catch (_) {}
}, [localKey]);
React.useEffect(() => {
handleChange();
fetchCoreConfig();
}, [fetchCoreConfig]);
// Poll cada 5s cuando modo = whip
React.useEffect(() => {
if (mode !== 'whip') return;
pollWhipStreams();
const id = setInterval(pollWhipStreams, 5000);
return () => clearInterval(id);
}, [mode, pollWhipStreams]);
React.useEffect(() => {
handleChange(mode);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleMode = (_e, newMode) => {
if (!newMode) return;
setMode(newMode);
handleChange(newMode);
};
const handleKeyChange = (e) => {
const k = e.target.value;
setLocalKey(k);
handleChange(mode, k);
};
const handleCopy = (text) => () => {
if (text && navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
setCopied(text);
setTimeout(() => setCopied(false), 2000);
});
}
};
return (
<React.Fragment>
{/* ── Mode toggle ── */}
<Grid item xs={12}>
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.6)', lineHeight: 1.7 }}>
<Trans>
Un cliente (navegador) abrirá la sala WebRTC para compartir su pantalla o cámara.
La señal llegará al Core vía RTMP y se distribuirá a todos los destinos configurados.
</Trans>
</Typography>
</Grid>
<Grid item xs={12}>
<TextField
variant="outlined"
fullWidth
label={<Trans>URL de la sala (compartir con el presentador)</Trans>}
value={roomUrl}
InputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: '0.8rem' } }}
helperText={<Trans>Room ID: {roomId}</Trans>}
/>
<ToggleButtonGroup value={mode} exclusive onChange={handleMode} size="small">
<ToggleButton value="whip">
<CloudUploadIcon style={{ fontSize: '1rem', marginRight: 4 }} />
<Trans>WHIP / OBS</Trans>
</ToggleButton>
<ToggleButton value="room">
<Icon style={{ fontSize: '1rem', marginRight: 4 }} />
<Trans>Sala WebRTC (navegador)</Trans>
</ToggleButton>
</ToggleButtonGroup>
</Grid>
{/* ── WHIP mode ── */}
{mode === 'whip' && (
<>
{configLoad && (
<Grid item xs={12} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<CircularProgress size={16} />
<Typography variant="body2" style={{ color: 'rgba(255,255,255,0.5)' }}>
<Trans>Cargando config WHIP del Core</Trans>
</Typography>
</Grid>
)}
{configErr && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#e74c3c' }}>
{configErr}
</Typography>
<Button size="small" startIcon={<RefreshIcon />} onClick={fetchCoreConfig} style={{ marginTop: 4 }}>
<Trans>Reintentar</Trans>
</Button>
</Grid>
)}
{!configLoad && coreConfig && !whipEnabled && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: '#e67e22' }}>
<Trans>El servidor WHIP del Core está deshabilitado. Actívalo en Ajustes WHIP Server.</Trans>
</Typography>
</Grid>
)}
{!configLoad && coreConfig && whipEnabled && (
<>
{/* Stream Key */}
<Grid item xs={12}>
<TextField
variant="outlined" fullWidth
label={<Trans>Stream Key</Trans>}
value={localKey}
onChange={handleKeyChange}
helperText={<Trans>OBS enviará a esta clave. Usa el ID del canal o escribe una personalizada.</Trans>}
/>
</Grid>
{/* URL de publicación para OBS */}
<Grid item xs={12}>
<Typography variant="caption" style={{ color: 'rgba(255,255,255,0.5)', display: 'block', marginBottom: 4 }}>
<Trans>URL para OBS Configuración Emisión Servicio: Custom (WHIP)</Trans>
</Typography>
<div style={{ display: 'flex', gap: 8 }}>
<TextField
variant="outlined" fullWidth size="small"
value={whipUrl}
InputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: '0.78rem' } }}
helperText={<Trans>Clave de stream en OBS: dejar vacía (ya va en la URL)</Trans>}
/>
<Button
variant="outlined" size="small"
onClick={handleCopy(whipUrl)}
startIcon={<ContentCopyIcon />}
style={{ whiteSpace: 'nowrap', minWidth: 90, height: 56 }}
>
{copied === whipUrl ? <Trans>¡Copiado!</Trans> : <Trans>Copiar</Trans>}
</Button>
</div>
</Grid>
{/* Estado en tiempo real */}
<Grid item xs={12}>
{active ? (
<Chip
icon={<FiberManualRecordIcon style={{ color: '#27ae60' }} />}
label={<Trans>Transmitiendo</Trans>}
size="small"
style={{ backgroundColor: 'rgba(39,174,96,0.15)', color: '#27ae60', border: '1px solid #27ae60' }}
/>
) : (
<Chip
icon={<FiberManualRecordIcon style={{ color: 'rgba(255,255,255,0.3)' }} />}
label={<Trans>Esperando publicador</Trans>}
size="small"
style={{ backgroundColor: 'rgba(255,255,255,0.05)', color: 'rgba(255,255,255,0.5)' }}
/>
)}
</Grid>
{/* Token warning */}
{token && (
<Grid item xs={12}>
<Typography variant="body2" style={{ color: 'rgba(255,200,0,0.8)', fontSize: '0.8rem' }}>
🔑 <Trans>El servidor requiere token. Ya está incluido en la URL anterior.</Trans>
</Typography>
</Grid>
)}
</>
)}
</>
)}
{/* ── Room mode ── */}
{mode === 'room' && (
<Grid item xs={12}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<TextField
variant="outlined" fullWidth
label={<Trans>URL de la sala (compartir con el presentador)</Trans>}
value={roomUrl}
InputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: '0.78rem' } }}
helperText={<Trans>Room ID: {localKey}</Trans>}
/>
<Button
variant="outlined" size="small"
onClick={handleCopy(roomUrl)}
startIcon={<ContentCopyIcon />}
style={{ whiteSpace: 'nowrap', minWidth: 90, height: 56 }}
>
{copied === roomUrl ? <Trans>¡Copiado!</Trans> : <Trans>Copiar</Trans>}
</Button>
</div>
</Grid>
)}
</React.Fragment>
);
}
Source.defaultProps = {
knownDevices: [],
settings: {},

View File

@ -1,10 +1,10 @@
import * as AVFoundation from './AVFoundation';
import * as InternalRTMP from './InternalRTMP';
import * as InternalSRT from './InternalSRT';
import * as InternalWHIP from './InternalWHIP';
import * as Network from './Network';
import * as Raspicam from './Raspicam';
import * as V4L from './V4L';
import * as WebRTCRoom from './WebRTCRoom';
class Registry {
constructor() {
@ -38,10 +38,10 @@ const registry = new Registry();
registry.Register(Network);
registry.Register(InternalRTMP);
registry.Register(InternalSRT);
registry.Register(InternalWHIP);
//registry.Register(InternalHLS);
registry.Register(AVFoundation);
registry.Register(Raspicam);
registry.Register(V4L);
registry.Register(WebRTCRoom);
export default registry;

View File

@ -27,7 +27,7 @@ export default function Video(props) {
<BoxText color="dark">
<WarningIcon fontSize="large" color="error" />
<Typography textAlign="center">
{props.sourceid === 'rtmp' || props.sourceid === 'hls' ? (
{props.sourceid === 'rtmp' || props.sourceid === 'hls' || props.sourceid === 'whip' ? (
<Trans>No live stream was detected. Please check the software that sends the stream.</Trans>
) : (
<Trans>Failed to verify the source. Please check the address.</Trans>

View File

@ -269,8 +269,8 @@ export default function Wizard(props) {
}
}
// WebRTC Room is always available (pseudo-source, no hardware required)
knownSources.push('webrtcroom');
// WHIP server is always available (Core handles it natively)
knownSources.push('whip');
let availableSources = [];
@ -299,26 +299,6 @@ export default function Wizard(props) {
handleNext = async () => {
const source = $sources.video;
// WebRTC Room: skip probe — relay sends H.264+AAC, use predefined streams
if ($sourceid === 'webrtcroom') {
const webrtcStreams = [
{ url: '', index: 0, stream: 0, type: 'video', codec: 'h264', width: 1280, height: 720, pix_fmt: 'yuv420p', sampling_hz: 0, layout: '', channels: 0 },
{ url: '', index: 0, stream: 1, type: 'audio', codec: 'aac', width: 0, height: 0, pix_fmt: '', sampling_hz: 44100, layout: 'stereo', channels: 2 },
];
const profile = M.preselectProfile('video', webrtcStreams, $profile, $skills.encoders);
setProfile({ ...$profile, ...profile });
setSources({
...$sources,
video: { ...source, streams: webrtcStreams },
});
setProbe({ ...$probe, probing: false, status: 'success' });
setStep('VIDEO RESULT');
return;
}
// Normal probe flow
setStep('VIDEO PROBE');
@ -373,9 +353,9 @@ export default function Wizard(props) {
const Component = s.component;
// Config: para webrtcroom usar el channelid directamente
const sourceConfig = $sourceid === 'webrtcroom'
? { channelid: _channelid }
// WHIP source fetches its own Core config; pass channelid so it builds the correct URL
const sourceConfig = $sourceid === 'whip'
? { channelid: _channelid, whip: $config.source?.network?.whip || {} }
: ($config.source ? $config.source[s.type] : null);
// STEP 2 - Source Settings
@ -394,6 +374,7 @@ export default function Wizard(props) {
config={sourceConfig}
settings={$sources.video.settings}
skills={$skills}
restreamer={props.restreamer}
onChange={handleChange}
onRefresh={handleRefresh}
onYoutubeMetadata={handleYoutubeMetadata}

View File

@ -4,6 +4,10 @@ import '@testing-library/jest-dom';
import Wizard from './index';
// InternalWHIP polls active streams with setInterval(5000ms).
// Complex tests have multiple await act() steps and can exceed 5s.
jest.setTimeout(15000);
const restreamer = {
SelectChannel: () => {
return 'test';
@ -55,10 +59,29 @@ const restreamer = {
token: 'foobar',
passphrase: 'bazfoobazfoo',
},
whip: {
enabled: true,
host: '192.168.1.15:8555',
local: 'localhost:8555',
token: '',
},
},
},
};
},
WhipUrl: () => ({
base_publish_url: 'http://192.168.1.15:8555/whip/',
base_sdp_url: 'http://localhost:8555/whip/',
has_token: false,
example_obs_url: 'http://192.168.1.15:8555/whip/<stream-key>',
input_address_template: '{whip:name=<stream-key>}',
}),
WhipStreamUrl: (name) => Promise.resolve({
publish_url: `http://192.168.1.15:8555/whip/${name}`,
sdp_url: `http://localhost:8555/whip/${name}/sdp`,
stream_key: name,
}),
WhipStreams: () => ([]),
Probe: (id, inputs) => {
let streams = [];
@ -161,7 +184,52 @@ const restreamer = {
channels: 2,
});
} else {
const name = inputs[0].address.split('/').pop();
// WHIP placeholder: mock simulates OBS transmitting (h264 + opus).
// In production, restreamer.Probe() expands {whip:name=X} → SDP URL first;
// in the UI mock we short-circuit to a successful probe result.
if (inputs[0].address.startsWith('{whip:')) {
streams.push({
url: inputs[0].address,
format: 'whip',
index: 0,
stream: 0,
language: 'und',
type: 'video',
codec: 'h264',
coder: '',
bitrate_kbps: 0,
duration_sec: 0,
fps: 30,
pix_fmt: 'yuv420p',
width: 1920,
height: 1080,
sampling_hz: 0,
layout: '',
channels: 0,
});
streams.push({
url: inputs[0].address,
format: 'whip',
index: 0,
stream: 1,
language: 'und',
type: 'audio',
codec: 'opus',
coder: '',
bitrate_kbps: 0,
duration_sec: 0,
fps: 0,
pix_fmt: '',
width: 0,
height: 0,
sampling_hz: 48000,
layout: 'stereo',
channels: 2,
});
return [{ streams }, null];
}
const name = inputs[0].address.split('/').pop();
const [video, audio] = name.split('-');
switch (video) {
@ -910,3 +978,168 @@ test('wizard: license', async () => {
fireEvent.click(button);
});
});
// ── WHIP E2E tests ──────────────────────────────────────────────────────────
test('wizard: WHIP source — OBS is live (h264 + opus), full flow to Save', async () => {
await act(async () => {
render(<Wizard restreamer={restreamer} />, {}, '/wizard/test', '/wizard/:channelid');
});
// Step 1 — choose WHIP server as input source.
// Wrap in act so InternalWHIP fully initializes (WhipUrl async → onChange → ready=true).
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /WHIP server/i }));
});
// Step 2 — probe: click Next; the mock returns h264+opus streams
let button = screen.getByRole('button', { name: 'Next' });
expect(button).toBeEnabled();
await act(async () => {
fireEvent.click(button);
});
// After probe the Wizard shows stream compatibility message
expect(screen.queryByText(/The video source is compatible/i)).toBeInTheDocument();
// Step 3 — confirm video stream
button = screen.getByRole('button', { name: 'Next' });
expect(button).toBeEnabled();
await act(async () => {
fireEvent.click(button);
});
// Audio step
expect(screen.queryByText(/Audio from device/i)).toBeInTheDocument();
button = screen.getByRole('button', { name: 'Next' });
expect(button).toBeEnabled();
await act(async () => {
fireEvent.click(button);
});
// Metadata step
expect(screen.queryByText(/Metadata/i)).toBeInTheDocument();
button = screen.getByRole('button', { name: 'Next' });
expect(button).toBeEnabled();
await act(async () => {
fireEvent.click(button);
});
// License + Save
expect(screen.queryByRole('heading', { name: /License/i })).toBeInTheDocument();
button = screen.getByRole('button', { name: 'Save' });
expect(button).toBeEnabled();
await act(async () => {
fireEvent.click(button);
});
});
test('wizard: WHIP probe — passes WHIP placeholder to restreamer.Probe', async () => {
// Regression guard: verifies that the Wizard passes the InternalWHIP
// placeholder address to restreamer.Probe() so the real implementation
// (restreamer.js) can expand it to the SDP URL.
//
// The Wizard is NOT responsible for expanding {whip:name=...} — that
// happens inside restreamer.js Probe(). The UI just hands off the address
// unchanged, and restreamer.js converts it to:
// http://localhost:8555/whip/<channelid>/sdp
// before forwarding to the Core.
const probeSpy = jest.fn(restreamer.Probe);
const spiedRestreamer = { ...restreamer, Probe: probeSpy };
await act(async () => {
render(<Wizard restreamer={spiedRestreamer} />, {}, '/wizard/test', '/wizard/:channelid');
});
// Wrap in act so InternalWHIP fully initializes before clicking Next
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /WHIP server/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
});
expect(probeSpy).toHaveBeenCalled();
const [, inputs] = probeSpy.mock.calls[0];
// The Wizard passes the WHIP placeholder (not an expanded URL).
// restreamer.js Probe() expands it before hitting the Core.
expect(inputs[0].address).toMatch(/^\{whip:name=/);
expect(inputs[0].address).toBe('{whip:name=test}');
});
test('wizard: WHIP probe retry — OBS starts late (0×0 then valid 1920×1080)', async () => {
// Simulates the case where the first probe returns 0×0 because OBS hasn't
// sent any frames yet. restreamer.js retries internally; the mock here
// simulates one failed probe (0×0) followed by a successful one (1920×1080).
// The Wizard must eventually reach VIDEO RESULT and proceed to Save.
//
// In production the retry loop lives inside restreamer.js Probe(). The mock
// replaces the entire restreamer.Probe(), so we implement the retry simulation
// directly in the mock using a call counter.
let probeCallCount = 0;
const retryRestreamer = {
...restreamer,
Probe: (channelid, inputs) => {
probeCallCount++;
if (probeCallCount === 1) {
// First call: simulate restreamer.js exhausting retries because
// OBS is not yet transmitting (Core returned 0×0 on all attempts).
// restreamer.js Probe() returns empty streams after max retries.
return [
{
streams: [],
log: ['WHIP: No stream detected. Make sure OBS is transmitting to this server.'],
},
null,
];
}
// Second call: OBS is now transmitting — return valid 1920×1080
return restreamer.Probe(channelid, inputs);
},
};
await act(async () => {
render(<Wizard restreamer={retryRestreamer} />, {}, '/wizard/test', '/wizard/:channelid');
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /WHIP server/i }));
});
// First probe call → empty streams → Wizard shows error for WHIP
let button = screen.getByRole('button', { name: 'Next' });
expect(button).toBeEnabled();
await act(async () => {
fireEvent.click(button);
});
// Wizard should return to VIDEO SETTINGS after probe failure.
// For WHIP, Video.js now shows the same "No live stream" message as RTMP.
expect(screen.queryByText(/No live stream was detected/i)).toBeInTheDocument();
// User tries again (hits Next once more)
button = screen.getByRole('button', { name: 'Next' });
expect(button).toBeEnabled();
await act(async () => {
fireEvent.click(button);
});
// Second probe → valid 1920×1080 → Wizard shows VIDEO RESULT
expect(screen.queryByText(/The video source is compatible/i)).toBeInTheDocument();
expect(probeCallCount).toBe(2);
});

View File

@ -348,6 +348,24 @@ export default function Main(props) {
const handleOpenRoom = () => {
if ($webrtcRoom.roomUrl) {
// Start publications (egresses) for this channel before opening the Room
(async () => {
try {
const processes = await props.restreamer.ListIngestEgresses(_channelid);
for (let p of processes) {
// skip player entry
if (p.service === 'player') continue;
try {
await props.restreamer.StartEgress(_channelid, p.id);
} catch (e) {
console.warn('[Main] StartEgress error:', e.message || e);
}
}
} catch (e) {
console.warn('[Main] Error starting publications:', e.message || e);
}
})();
const w = 820, h = 700;
const left = Math.max(0, Math.round(window.screen.width / 2 - w / 2));
const top = Math.max(0, Math.round(window.screen.height / 2 - h / 2));

View File

@ -369,6 +369,42 @@ const configValues = {
return null;
},
},
'whip.enable': {
tab: 'whip',
set: (config, value) => {
config.whip.enable = !config.whip.enable;
},
unset: (config) => {
delete config.whip.enable;
},
validate: (config) => {
return null;
},
},
'whip.address': {
tab: 'whip',
set: (config, value) => {
config.whip.address = value;
},
unset: (config) => {
delete config.whip.address;
},
validate: (config) => {
return null;
},
},
'whip.token': {
tab: 'whip',
set: (config, value) => {
config.whip.token = value;
},
unset: (config) => {
delete config.whip.token;
},
validate: (config) => {
return null;
},
},
'storage.cors.allow_all': {
tab: 'storage',
set: (config, value) => {
@ -725,6 +761,7 @@ export default function Settings(props) {
storage: { errors: false, messages: [] },
rtmp: { errors: false, messages: [] },
srt: { errors: false, messages: [] },
whip: { errors: false, messages: [] },
logging: { errors: false, messages: [] },
service: { errors: false, messages: [] },
});
@ -818,6 +855,28 @@ export default function Settings(props) {
config.rtmp.address_tls = config.rtmp.address_tls.split(':').join('');
config.srt.address = config.srt.address.split(':').join('');
if (config.whip) {
config.whip.address = config.whip.address.split(':').join('');
} else {
// Core didn't return a whip section (never saved to disk).
// Use WhipUrl() to detect live server state: enable flag and port.
// Token cannot be recovered this way — it remains blank.
config.whip = { enable: false, address: '8555', token: '' };
if (props.restreamer.WhipUrl) {
const whipLive = await props.restreamer.WhipUrl();
if (whipLive !== null) {
config.whip.enable = true;
// Extract port from base_publish_url (e.g. "http://host:8555/whip/")
if (whipLive.base_publish_url) {
try {
const u = new URL(whipLive.base_publish_url);
if (u.port) config.whip.address = u.port;
} catch (_) {}
}
}
}
}
if (config.tls.auto === true) {
config.tls.enable = true;
}
@ -926,6 +985,39 @@ export default function Settings(props) {
clearInterval(logTimer.current);
}
// Re-read WHIP config from Core each time the WHIP tab is activated.
// This recovers the correct values after a Core restart without needing
// a full page reload — because load() only runs once on mount.
// Skip if the user has unsaved changes to avoid discarding their edits.
if (value === 'whip' && !$config.modified) {
const data = await props.restreamer.Config();
if (data !== null && data.config && data.config.whip) {
// Happy path: Core has whip in its config (saved to disk at least once).
const rawWhip = data.config.whip;
const whip = {
enable: rawWhip.enable ?? false,
address: (rawWhip.address || ':8555').split(':').join(''),
token: rawWhip.token || '',
};
setConfig((prev) => ({ ...prev, data: { ...prev.data, whip } }));
} else if (data !== null && props.restreamer.WhipUrl) {
// Fallback: whip section not on disk yet — use live server info.
// This happens when the Core fix was applied but the user hasn't
// saved Settings once yet to persist the whip section to disk.
const whipLive = await props.restreamer.WhipUrl();
if (whipLive !== null) {
const whip = { enable: true, address: '8555', token: '' };
if (whipLive.base_publish_url) {
try {
const u = new URL(whipLive.base_publish_url);
if (u.port) whip.address = u.port;
} catch (_) {}
}
setConfig((prev) => ({ ...prev, data: { ...prev.data, whip } }));
}
}
}
setTab(value);
};
@ -976,6 +1068,10 @@ export default function Settings(props) {
config.rtmp.app = !config.rtmp.app.startsWith('/') ? '/' + config.rtmp.app : config.rtmp.app;
config.srt.address = ':' + config.srt.address;
if (config.whip) {
config.whip.address = ':' + config.whip.address;
}
if (config.tls.auto === true) {
config.tls.enable = true;
} else {
@ -1276,6 +1372,7 @@ export default function Settings(props) {
{$expert === true && <ErrorTab className="tab" label={<Trans>Storage</Trans>} value="storage" errors={$tabs.storage.errors} />}
<ErrorTab className="tab" label={<Trans>RTMP</Trans>} value="rtmp" errors={$tabs.rtmp.errors} />
<ErrorTab className="tab" label={<Trans>SRT</Trans>} value="srt" errors={$tabs.srt.errors} />
<ErrorTab className="tab" label={<Trans>WHIP</Trans>} value="whip" errors={$tabs.whip.errors} />
{$expert === true && <ErrorTab className="tab" label={<Trans>Logging</Trans>} value="logging" errors={$tabs.logging.errors} />}
<Tab className="tab" label={<Trans>Integrations</Trans>} value="integrations" />
</Tabs>
@ -2122,6 +2219,55 @@ export default function Settings(props) {
</Grid>
</Grid>
</TabPanel>
<TabPanel value={$tab} index="whip" className="panel">
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="h2">
<Trans>WHIP Server</Trans>
</Typography>
</Grid>
<Grid item xs={12}>
<Checkbox
label={<Trans>WHIP server</Trans>}
checked={config.whip?.enable || false}
disabled={env('whip.enable')}
onChange={handleChange('whip.enable')}
/>{' '}
{env('whip.enable') && <Env style={{ marginRight: '2em' }} />}
<ErrorBox configvalue="whip.enable" messages={$tabs.whip.messages} />
</Grid>
<Grid item xs={12}>
<Divider />
</Grid>
<Grid item xs={6} md={4}>
<TextField
type="number"
label={<Trans>Port</Trans>}
env={env('whip.address')}
disabled={env('whip.address') || !config.whip?.enable}
value={config.whip?.address || '8555'}
onChange={handleChange('whip.address')}
/>
<ErrorBox configvalue="whip.address" messages={$tabs.whip.messages} />
<Typography variant="caption">
<Trans>WHIP server listen port.</Trans>
</Typography>
</Grid>
<Grid item xs={6} md={8}>
<Password
label={<Trans>Token</Trans>}
env={env('whip.token')}
disabled={env('whip.token') || !config.whip?.enable}
value={config.whip?.token || ''}
onChange={handleChange('whip.token')}
/>
<ErrorBox configvalue="whip.token" messages={$tabs.whip.messages} />
<Typography variant="caption">
<Trans>Optional token for WHIP stream authentication.</Trans>
</Typography>
</Grid>
</Grid>
</TabPanel>
<TabPanel value={$tab} index="logging" className="panel">
<Grid container spacing={2}>
<Grid item xs={12}>

View File

@ -0,0 +1,376 @@
/**
* E2E integration tests Settings WHIP config save / load cycle
*
* Purpose: verify that the WHIP tab in Settings correctly:
* 1. Loads `whip.{enable, address, token}` from the Core config response
* 2. Sends the mutated values back in the correct shape when the user saves
* (i.e. `address` is re-serialised as `:8555`, `enable` is a boolean,
* `token` is the updated string)
*
* If this test passes but the Core still loses the value after restart,
* the bug is 100% in the Core Go handler (missing `cfg.WHIP = rscfg.WHIP`).
* If this test fails, the bug is in the UI serialisation path.
*/
import React from 'react';
import { render, screen, fireEvent, act, waitFor } from '../utils/testing';
import '@testing-library/jest-dom';
import Settings from './Settings';
// ── Minimal Core config fixture ──────────────────────────────────────────────
//
// Matches the shape returned by restreamer.Config() → GET /v3/config.
// Only the fields actually accessed in Settings.load() are required.
function makeCoreConfig(whipOverride = {}) {
const now = new Date();
return {
config: {
id: 'test-id',
name: 'Test Restreamer',
update_check: false,
address: ':8080',
host: { name: ['192.168.1.15'], auto: true },
tls: {
enable: false,
auto: false,
address: ':8443',
email: '',
},
log: { level: 'info', max_lines: 1000, max_history: 3 },
storage: {
cors: { origins: [] },
disk: {
max_size_mbytes: 0,
cache: {
enable: false,
max_size_mbytes: 0,
ttl_seconds: 0,
max_file_size_mbytes: 0,
types: { allow: [], block: [] },
},
},
memory: {
auth: { enable: false, username: '', password: '' },
max_size_mbytes: 0,
purge: false,
},
},
sessions: {
enable: false,
ip_ignorelist: [],
session_timeout_sec: 30,
persist: false,
max_bitrate_mbit: 0,
max_sessions: 0,
},
rtmp: {
enable: false,
enable_tls: false,
address: ':1935',
address_tls: ':1936',
app: '/live',
token: '',
},
srt: {
enable: false,
address: ':6000',
token: '',
passphrase: '',
},
whip: {
enable: false,
address: ':8555',
token: '',
...whipOverride,
},
ffmpeg: { log: { max_lines: 200, max_history: 3 } },
api: { auth: { enable: false, username: '', password: '' } },
service: { enable: false, token: '' },
},
overrides: [],
loaded_at: now,
updated_at: now,
created_at: now,
};
}
// ── Mock restreamer ───────────────────────────────────────────────────────────
function makeRestreamer(coreConfig, whipUrlResponse = null) {
return {
Config: jest.fn().mockResolvedValue(coreConfig),
ConfigSet: jest.fn().mockResolvedValue([null, null]),
ConfigReload: jest.fn().mockResolvedValue(true),
Log: jest.fn().mockResolvedValue([]),
IsExpert: jest.fn().mockReturnValue(false),
HasUpdates: jest.fn().mockReturnValue(false),
CheckForUpdates: jest.fn().mockReturnValue(false),
HasService: jest.fn().mockReturnValue(false),
SetExpert: jest.fn(),
SetCheckForUpdates: jest.fn(),
CreatedAt: jest.fn().mockReturnValue(new Date()),
About: jest.fn().mockResolvedValue(null),
Validate: jest.fn().mockResolvedValue(true),
Login: jest.fn().mockResolvedValue(true),
// WhipUrl is used by handleChangeTab to refresh WHIP state after Core restart
WhipUrl: jest.fn().mockResolvedValue(whipUrlResponse),
};
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Navigate to the WHIP tab in Settings and wait for the panel to be visible */
async function renderAndNavigateToWhipTab(restreamer) {
let utils;
await act(async () => {
utils = render(<Settings restreamer={restreamer} />, {}, '/settings/whip', '/settings/:tab');
});
// The component loads config async — wait until the WHIP checkbox appears
await waitFor(() => expect(screen.getByText('WHIP Server')).toBeInTheDocument(), { timeout: 3000 });
return utils;
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('Settings WHIP tab — load', () => {
test('loads whip.enable=false from Core config (disabled by default)', async () => {
const restreamer = makeRestreamer(makeCoreConfig());
await renderAndNavigateToWhipTab(restreamer);
// The checkbox label exists; checkbox should be unchecked
const checkbox = screen.getByRole('checkbox', { name: /WHIP server/i });
expect(checkbox).not.toBeChecked();
});
test('loads whip.enable=true when Core returns enable:true', async () => {
const restreamer = makeRestreamer(makeCoreConfig({ enable: true }));
await renderAndNavigateToWhipTab(restreamer);
const checkbox = screen.getByRole('checkbox', { name: /WHIP server/i });
expect(checkbox).toBeChecked();
});
test('loads port from whip.address (strips leading colon)', async () => {
const restreamer = makeRestreamer(makeCoreConfig({ enable: true, address: ':9000' }));
await renderAndNavigateToWhipTab(restreamer);
// Port field should show "9000" (colon stripped by load())
expect(screen.getByDisplayValue('9000')).toBeInTheDocument();
});
test('calls Config() exactly once on mount', async () => {
const restreamer = makeRestreamer(makeCoreConfig());
await renderAndNavigateToWhipTab(restreamer);
expect(restreamer.Config).toHaveBeenCalledTimes(1);
});
});
describe('Settings WHIP tab — save round-trip', () => {
test('ConfigSet receives whip.enable toggled to true', async () => {
const restreamer = makeRestreamer(makeCoreConfig({ enable: false }));
await renderAndNavigateToWhipTab(restreamer);
// Toggle the WHIP server checkbox ON
await act(async () => {
fireEvent.click(screen.getByRole('checkbox', { name: /WHIP server/i }));
});
// Click Save
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
expect(restreamer.ConfigSet).toHaveBeenCalledTimes(1);
const sentConfig = restreamer.ConfigSet.mock.calls[0][0];
expect(sentConfig.whip.enable).toBe(true);
});
test('ConfigSet receives whip.address with colon prefix (:8555)', async () => {
const restreamer = makeRestreamer(makeCoreConfig({ enable: true, address: ':8555' }));
await renderAndNavigateToWhipTab(restreamer);
// Modify a field to set $config.modified = true (toggle enable off then on)
await act(async () => {
fireEvent.click(screen.getByRole('checkbox', { name: /WHIP server/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole('checkbox', { name: /WHIP server/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
const sentConfig = restreamer.ConfigSet.mock.calls[0][0];
expect(sentConfig.whip.address).toBe(':8555');
});
test('ConfigSet receives updated token value', async () => {
const restreamer = makeRestreamer(makeCoreConfig({ enable: true, address: ':8555', token: '' }));
await renderAndNavigateToWhipTab(restreamer);
// The Password component renders an accessible input associated via label
const tokenInput = screen.getByLabelText(/token/i);
await act(async () => {
fireEvent.change(tokenInput, { target: { value: 'heavy666' } });
});
// $config.modified is now true → Save button is enabled
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
const sentConfig = restreamer.ConfigSet.mock.calls[0][0];
expect(sentConfig.whip.token).toBe('heavy666');
});
test('full round-trip: enable=true + address=:8555 + token=heavy666 all present', async () => {
const restreamer = makeRestreamer(makeCoreConfig({ enable: false, address: ':8555', token: '' }));
await renderAndNavigateToWhipTab(restreamer);
// 1. Enable WHIP
await act(async () => {
fireEvent.click(screen.getByRole('checkbox', { name: /WHIP server/i }));
});
// 2. Change token
const tokenInput = screen.getByLabelText(/token/i);
await act(async () => {
fireEvent.change(tokenInput, { target: { value: 'heavy666' } });
});
// 3. Save
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
expect(restreamer.ConfigSet).toHaveBeenCalledTimes(1);
const sentConfig = restreamer.ConfigSet.mock.calls[0][0];
// These three assertions pinpoint exactly what the Core receives.
// If the test passes → the UI is sending the right data → bug is in the Core.
// If the test fails → bug is in the UI serialisation path (Settings.js handleSave).
expect(sentConfig.whip).toEqual({
enable: true,
address: ':8555',
token: 'heavy666',
});
});
test('whip object is NOT stripped when overrides list does not include whip fields', async () => {
const restreamer = makeRestreamer(makeCoreConfig({ enable: true }));
await renderAndNavigateToWhipTab(restreamer);
// Need to modify something to enable the Save button ($config.modified = true)
// Toggle enable OFF so the form is dirty, then save
await act(async () => {
fireEvent.click(screen.getByRole('checkbox', { name: /WHIP server/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
const sentConfig = restreamer.ConfigSet.mock.calls[0][0];
expect(sentConfig.whip).toBeDefined();
expect(typeof sentConfig.whip.enable).toBe('boolean');
expect(typeof sentConfig.whip.address).toBe('string');
expect(sentConfig.whip.address).toMatch(/^:/); // must start with ':'
});
});
describe('Settings WHIP tab — post-restart recovery', () => {
test('re-reads WHIP config from Core when WHIP tab is clicked (no unsaved changes)', async () => {
// Simulate: app loaded with stale config (whip disabled),
// but Core was restarted and now has enable=true + token.
const staleConfig = makeCoreConfig({ enable: false, token: '' });
const freshAfterRestart = makeCoreConfig({ enable: true, address: ':8555', token: 'heavy666' });
// First call (on mount) returns stale, second call (on tab click) returns fresh.
const restreamer = makeRestreamer(staleConfig);
restreamer.Config.mockResolvedValueOnce(staleConfig).mockResolvedValueOnce(freshAfterRestart);
// Start on the General tab so the WHIP panel is not yet rendered.
await act(async () => {
render(<Settings restreamer={restreamer} />, {}, '/settings/general', '/settings/:tab');
});
// Wait for Settings to be ready — any visible nav tab confirms load() completed.
await waitFor(() => expect(screen.getByRole('tab', { name: /WHIP/i })).toBeInTheDocument(), { timeout: 3000 });
// Click WHIP tab — triggers handleChangeTab which calls Config() a second time
// and merges the fresh whip section into $config.data.
await act(async () => {
fireEvent.click(screen.getByRole('tab', { name: /WHIP/i }));
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
// WHIP panel is now rendered with the fresh values from Core.
await waitFor(() => expect(screen.getByText('WHIP Server')).toBeInTheDocument(), { timeout: 3000 });
expect(screen.getByRole('checkbox', { name: /WHIP server/i })).toBeChecked();
expect(screen.getByDisplayValue('8555')).toBeInTheDocument();
});
test('falls back to WhipUrl() when config.whip missing from disk (Core fix not saved yet)', async () => {
// Scenario: Core Go fix was applied, WHIP server is running, but the whip
// section was never saved to disk (user has not clicked Save after the fix).
// GET /v3/config returns no whip section; GET /v3/whip/url returns live state.
const configWithoutWhip = makeCoreConfig();
delete configWithoutWhip.config.whip;
const restreamer = makeRestreamer(configWithoutWhip, {
base_publish_url: 'http://192.168.1.15:9000/whip/',
base_sdp_url: 'http://localhost:9000/whip/',
has_token: false,
});
// Both Config() calls (load + tab click) return config without whip
restreamer.Config.mockResolvedValue(configWithoutWhip);
await act(async () => {
render(<Settings restreamer={restreamer} />, {}, '/settings/general', '/settings/:tab');
});
await waitFor(() => expect(screen.getByRole('tab', { name: /WHIP/i })).toBeInTheDocument(), { timeout: 3000 });
// Click WHIP tab — handleChangeTab should call WhipUrl() and populate from it
await act(async () => {
fireEvent.click(screen.getByRole('tab', { name: /WHIP/i }));
});
await act(async () => { await Promise.resolve(); });
await act(async () => { await Promise.resolve(); });
await waitFor(() => expect(screen.getByText('WHIP Server')).toBeInTheDocument(), { timeout: 3000 });
// Port extracted from base_publish_url "http://192.168.1.15:9000/whip/" → 9000
expect(screen.getByDisplayValue('9000')).toBeInTheDocument();
// WHIP server is running → enable should be checked
expect(screen.getByRole('checkbox', { name: /WHIP server/i })).toBeChecked();
});
test('does NOT re-read config when user has unsaved changes (modified=true)', async () => {
const config = makeCoreConfig({ enable: false, token: '' });
const restreamer = makeRestreamer(config);
await act(async () => {
render(<Settings restreamer={restreamer} />, {}, '/settings/whip', '/settings/:tab');
});
await waitFor(() => expect(screen.getByText('WHIP Server')).toBeInTheDocument(), { timeout: 3000 });
// User edits something → modified=true
await act(async () => {
fireEvent.click(screen.getByRole('checkbox', { name: /WHIP server/i }));
});
const callCountBefore = restreamer.Config.mock.calls.length;
// Click WHIP tab again — should NOT call Config() because modified=true
await act(async () => {
fireEvent.click(screen.getByRole('tab', { name: /WHIP/i }));
});
await act(async () => { await Promise.resolve(); });
expect(restreamer.Config.mock.calls.length).toBe(callCountBefore);
});
});

View File

@ -1173,6 +1173,11 @@
resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz"
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
"@bufbuild/protobuf@^1.10.0", "@bufbuild/protobuf@^1.10.1":
version "1.10.1"
resolved "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz"
integrity sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==
"@csstools/normalize.css@*":
version "12.1.1"
resolved "https://registry.npmjs.org/@csstools/normalize.css/-/normalize.css-12.1.1.tgz"
@ -1963,6 +1968,13 @@
"@babel/runtime" "^7.20.13"
"@lingui/core" "4.11.4"
"@livekit/protocol@^1.43.1":
version "1.45.0"
resolved "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.0.tgz"
integrity sha512-z22Ej7RRBFm5uVZpU7kBHOdDwZV6Hz+1crCOrse2g7yx8TcHXG0bKnOKwyN/meD233nEDlU2IHNCoT8Vq8lvtg==
dependencies:
"@bufbuild/protobuf" "^1.10.0"
"@messageformat/parser@^5.0.0":
version "5.1.0"
resolved "https://registry.npmjs.org/@messageformat/parser/-/parser-5.1.0.tgz"
@ -3907,6 +3919,16 @@ camelcase-css@^2.0.1:
resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz"
integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==
camelcase-keys@^9.0.0:
version "9.1.3"
resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-9.1.3.tgz"
integrity sha512-Rircqi9ch8AnZscQcsA1C47NFdaO3wukpmIRzYcDOrmvgt78hM/sj5pZhZNec2NM12uk5vTwRHZ4anGcrC4ZTg==
dependencies:
camelcase "^8.0.0"
map-obj "5.0.0"
quick-lru "^6.1.1"
type-fest "^4.3.2"
camelcase@^5.3.1:
version "5.3.1"
resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz"
@ -3922,6 +3944,11 @@ camelcase@^7.0.0:
resolved "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz"
integrity sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==
camelcase@^8.0.0:
version "8.0.0"
resolved "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz"
integrity sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==
caniuse-api@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz"
@ -7495,6 +7522,11 @@ jiti@^1.17.1, jiti@^1.19.1:
resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz"
integrity sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==
jose@^5.1.2:
version "5.10.0"
resolved "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz"
integrity sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==
js-sha256@^0.10.1:
version "0.10.1"
resolved "https://registry.npmjs.org/js-sha256/-/js-sha256-0.10.1.tgz"
@ -7806,6 +7838,16 @@ lines-and-columns@^1.1.6:
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
livekit-server-sdk@^2.15.0:
version "2.15.0"
resolved "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.15.0.tgz"
integrity sha512-HmzjWnwEwwShu8yUf7VGFXdc+BuMJR5pnIY4qsdlhqI9d9wDgq+4cdTEHg0NEBaiGnc6PCOBiaTYgmIyVJ0S9w==
dependencies:
"@bufbuild/protobuf" "^1.10.1"
"@livekit/protocol" "^1.43.1"
camelcase-keys "^9.0.0"
jose "^5.1.2"
loader-runner@^4.2.0:
version "4.3.0"
resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz"
@ -7968,6 +8010,11 @@ makeerror@1.0.12:
dependencies:
tmpl "1.0.5"
map-obj@5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/map-obj/-/map-obj-5.0.0.tgz"
integrity sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==
mdast-util-from-markdown@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.0.tgz"
@ -9672,6 +9719,11 @@ queue-microtask@^1.2.2:
resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
quick-lru@^6.1.1:
version "6.1.2"
resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz"
integrity sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==
raf@^3.4.1:
version "3.4.1"
resolved "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz"
@ -11323,6 +11375,11 @@ type-fest@^2.13.0:
resolved "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz"
integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==
type-fest@^4.3.2:
version "4.41.0"
resolved "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz"
integrity sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==
type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz"