feat: Livekit bare no recording nor pipeline

This commit is contained in:
Juan
2026-04-01 13:54:26 -05:00
parent b570d202dc
commit 6d84794e36
30 changed files with 1724 additions and 37 deletions

2
.gitignore vendored
View File

@@ -3,6 +3,8 @@ server/.env
server/.env.production server/.env.production
.env .env
Caddyfile Caddyfile
livekit.yaml
egress.yaml
.env.hatchet .env.hatchet
server/exportdanswer server/exportdanswer
.vercel .vercel

View File

@@ -406,6 +406,40 @@ services:
volumes: volumes:
- server_data:/app/data - server_data:/app/data
# ===========================================================
# LiveKit — self-hosted open-source video platform
# Activated via --profile livekit (auto-detected from LIVEKIT_API_KEY in server/.env)
# ===========================================================
livekit-server:
image: livekit/livekit-server:v1.10.1
profiles: [livekit]
restart: unless-stopped
ports:
- "7880:7880" # Signaling (HTTP/WS)
- "7881:7881" # WebRTC over TCP
- "44200-44300:44200-44300/udp" # WebRTC ICE (avoids macOS ephemeral 49152-65535 and Reflector 40000-40100)
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
command: ["--config", "/etc/livekit.yaml"]
depends_on:
redis:
condition: service_started
livekit-egress:
image: livekit/egress:v1.12.0
profiles: [livekit]
restart: unless-stopped
environment:
EGRESS_CONFIG_FILE: /etc/egress.yaml
volumes:
- ./egress.yaml:/etc/egress.yaml:ro
depends_on:
redis:
condition: service_started
livekit-server:
condition: service_started
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:

286
docsv2/livekit-setup.md Normal file
View File

@@ -0,0 +1,286 @@
# LiveKit Setup (Self-Hosted Video Platform)
LiveKit is the recommended open-source, self-hosted video platform for Reflector. It replaces Daily.co for deployments that need free, fully self-hosted video rooms with per-participant audio recording.
> LiveKit runs alongside Daily.co and Whereby — you choose the platform per room. Existing Daily/Whereby setups are not affected.
## What LiveKit Provides
- **Video/audio rooms** — WebRTC-based conferencing via `livekit-server` (Go SFU)
- **Per-participant audio recording** — Track Egress writes each participant's audio to S3 as a separate OGG/Opus file (no composite video, no Chrome dependency)
- **S3-compatible storage** — works with Garage, MinIO, AWS S3, or any S3-compatible provider via `force_path_style`
- **Webhook events** — participant join/leave, egress start/end, room lifecycle
- **JWT access tokens** — per-participant tokens with granular permissions
## Architecture
```
┌─────────────────┐
Participants ────>│ livekit-server │ :7880 (WS signaling)
(browser) │ (Go SFU) │ :7881 (TCP RTC)
│ │ :44200-44300/udp (ICE)
└────────┬────────┘
│ media forwarding
┌────────┴────────┐
│ livekit-egress │ Track Egress
│ (per-track OGG) │ writes to S3
└────────┬────────┘
┌────────┴────────┐
│ S3 Storage │ Garage / MinIO / AWS
│ (audio tracks) │
└─────────────────┘
```
Both services share Redis with the existing Reflector stack (same instance, same db).
## Quick Start
### Option 1: Via Setup Script (Recommended)
Pass `--livekit` to the setup script. It generates all credentials and config automatically:
```bash
# First run — --livekit generates credentials and config files
./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy
# Re-runs — LiveKit is auto-detected from existing LIVEKIT_API_KEY in server/.env
./scripts/setup-selfhosted.sh
```
The `--livekit` flag will:
1. Generate `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET` (random credentials)
2. Set `LIVEKIT_URL`, `LIVEKIT_PUBLIC_URL`, and storage credentials in `server/.env`
3. Generate `livekit.yaml` and `egress.yaml` config files
4. Set `DEFAULT_VIDEO_PLATFORM=livekit`
5. Enable the `livekit` Docker Compose profile
6. Start `livekit-server` and `livekit-egress` containers
On subsequent re-runs (without flags), the script detects the existing `LIVEKIT_API_KEY` in `server/.env` and re-enables the profile automatically.
### Option 2: Manual Setup
If you prefer manual configuration:
1. **Generate credentials:**
```bash
export LK_KEY="reflector_$(openssl rand -hex 8)"
export LK_SECRET="$(openssl rand -hex 32)"
```
2. **Add to `server/.env`:**
```env
# LiveKit connection
LIVEKIT_URL=ws://livekit-server:7880
LIVEKIT_API_KEY=$LK_KEY
LIVEKIT_API_SECRET=$LK_SECRET
LIVEKIT_PUBLIC_URL=wss://your-domain:7880 # or ws://your-ip:7880
# LiveKit egress S3 storage (reuse transcript storage or configure separately)
LIVEKIT_STORAGE_AWS_BUCKET_NAME=reflector-bucket
LIVEKIT_STORAGE_AWS_REGION=us-east-1
LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID=your-key
LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY=your-secret
LIVEKIT_STORAGE_AWS_ENDPOINT_URL=http://garage:3900 # for Garage/MinIO
# Set LiveKit as default platform for new rooms
DEFAULT_VIDEO_PLATFORM=livekit
```
3. **Create `livekit.yaml`:**
```yaml
port: 7880
rtc:
tcp_port: 7881
port_range_start: 44200
port_range_end: 44300
redis:
address: redis:6379
keys:
your_api_key: your_api_secret
webhook:
urls:
- http://server:1250/v1/livekit/webhook
api_key: your_api_key
logging:
level: info
room:
empty_timeout: 300
max_participants: 0
```
4. **Create `egress.yaml`:**
```yaml
api_key: your_api_key
api_secret: your_api_secret
ws_url: ws://livekit-server:7880
health_port: 7082
log_level: info
session_limits:
file_output_max_duration: 4h
```
5. **Start with the livekit profile:**
```bash
docker compose -f docker-compose.selfhosted.yml --profile livekit up -d livekit-server livekit-egress
```
## Environment Variables Reference
### Required
| Variable | Description | Example |
|----------|-------------|---------|
| `LIVEKIT_URL` | Internal WebSocket URL (server -> LiveKit) | `ws://livekit-server:7880` |
| `LIVEKIT_API_KEY` | API key for authentication | `reflector_a1b2c3d4e5f6` |
| `LIVEKIT_API_SECRET` | API secret for token signing and webhooks | `64-char hex string` |
### Recommended
| Variable | Description | Example |
|----------|-------------|---------|
| `LIVEKIT_PUBLIC_URL` | Public WebSocket URL (browser -> LiveKit). **Must be reachable from participants' browsers**, not a Docker-internal address. Without `--domain`, set to `ws://<server-ip>:7880`. With `--domain`, set to `wss://<domain>:7880`. | `wss://reflector.example.com:7880` |
| `LIVEKIT_WEBHOOK_SECRET` | Webhook verification secret. Defaults to `LIVEKIT_API_SECRET` if not set. Only needed if you want a separate secret for webhooks. | (same as API secret) |
| `DEFAULT_VIDEO_PLATFORM` | Default platform for new rooms | `livekit` |
### Storage (for Track Egress)
Track Egress writes per-participant audio files to S3. If not configured, falls back to the transcript storage credentials.
| Variable | Description | Example |
|----------|-------------|---------|
| `LIVEKIT_STORAGE_AWS_BUCKET_NAME` | S3 bucket for egress output | `reflector-bucket` |
| `LIVEKIT_STORAGE_AWS_REGION` | S3 region | `us-east-1` |
| `LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID` | S3 access key | `GK...` |
| `LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY` | S3 secret key | `...` |
| `LIVEKIT_STORAGE_AWS_ENDPOINT_URL` | S3 endpoint (for Garage/MinIO) | `http://garage:3900` |
## Docker Compose Services
Two services are added under the `livekit` profile in `docker-compose.selfhosted.yml`:
### livekit-server
| Setting | Value |
|---------|-------|
| Image | `livekit/livekit-server:v1.10.1` |
| Ports | 7880 (signaling), 7881 (TCP RTC), 44200-44300/udp (ICE) |
| Config | `./livekit.yaml` mounted at `/etc/livekit.yaml` |
| Depends on | Redis |
### livekit-egress
| Setting | Value |
|---------|-------|
| Image | `livekit/egress:v1.10.1` |
| Config | `./egress.yaml` mounted at `/etc/egress.yaml` |
| Depends on | Redis, livekit-server |
No `--cap-add=SYS_ADMIN` is needed because Track Egress does not use Chrome (that's only for Room Composite video recording, which we don't use).
## Port Ranges
| Range | Protocol | Service | Notes |
|-------|----------|---------|-------|
| 7880 | TCP | LiveKit signaling | WebSocket connections from browsers (direct, no Caddy) |
| 7881 | TCP | LiveKit RTC over TCP | Fallback when UDP is blocked |
| 44200-44300 | UDP | LiveKit ICE | WebRTC media. Avoids collision with Reflector WebRTC (40000-40100) and macOS ephemeral ports (49152-65535) |
### TLS / Caddy Integration
When `--caddy` is enabled (HTTPS), the setup script automatically:
1. Adds a `/lk-ws` reverse proxy route to the Caddyfile that proxies `wss://domain/lk-ws``ws://livekit-server:7880`
2. Sets `LIVEKIT_PUBLIC_URL` to `wss://<domain>/lk-ws` (or `wss://<ip>/lk-ws`)
This avoids mixed-content blocking (browsers reject `ws://` connections on `https://` pages). Caddy handles TLS termination; LiveKit server itself runs plain WebSocket internally.
Without `--caddy`, browsers connect directly to LiveKit on port 7880 via `ws://`.
| Deployment | `LIVEKIT_PUBLIC_URL` | How it works |
|---|---|---|
| localhost, no Caddy | `ws://localhost:7880` | Direct connection |
| LAN IP, no Caddy | `ws://192.168.1.x:7880` | Direct connection |
| IP + Caddy | `wss://192.168.1.x/lk-ws` | Caddy terminates TLS, proxies to LiveKit |
| Domain + Caddy | `wss://example.com/lk-ws` | Caddy terminates TLS, proxies to LiveKit |
## Webhook Endpoint
LiveKit sends webhook events to `POST /v1/livekit/webhook`. Events handled:
| Event | Action |
|-------|--------|
| `participant_joined` | Logs participant join, updates meeting state |
| `participant_left` | Logs participant leave |
| `egress_started` | Logs recording start |
| `egress_ended` | Logs recording completion with output file info |
| `room_started` / `room_finished` | Logs room lifecycle |
Webhooks are authenticated via JWT in the `Authorization` header, verified using the API secret.
## Frontend
The LiveKit room component uses `@livekit/components-react` with the prebuilt `<VideoConference>` UI. It includes:
- Recording consent dialog (same as Daily/Whereby)
- Email transcript button (feature-gated)
- Extensible overlay buttons for custom actions
When a user joins a LiveKit room, the backend generates a JWT access token and returns it in the `room_url` query parameter. The frontend parses this and passes it to the LiveKit React SDK.
## Separate Server Deployment
For larger deployments (15+ participants, multiple simultaneous rooms), LiveKit can run on a dedicated server:
1. Run `livekit-server` and `livekit-egress` on a separate machine
2. Point `LIVEKIT_URL` to the remote LiveKit server (e.g., `ws://livekit-host:7880`)
3. Set `LIVEKIT_PUBLIC_URL` to the public-facing URL (e.g., `wss://livekit.example.com`)
4. Configure the remote LiveKit's `webhook.urls` to point back to the Reflector server
5. Both need access to the same Redis (or configure LiveKit's own Redis)
6. Both need access to the same S3 storage
## Troubleshooting
### LiveKit server not starting
```bash
# Check logs
docker compose -f docker-compose.selfhosted.yml logs livekit-server --tail 30
# Verify config
cat livekit.yaml
# Common issues:
# - Redis not reachable (check redis service is running)
# - Port 7880 already in use
# - Invalid API key format in livekit.yaml
```
### Participants can't connect
```bash
# Check that LIVEKIT_PUBLIC_URL is accessible from the browser
# It must be the URL the browser can reach, not the Docker-internal URL
# Check firewall allows ports 7880, 7881, and 44200-44300/udp
sudo ufw status # or iptables -L
# Verify the access token is being generated
docker compose -f docker-compose.selfhosted.yml logs server | grep livekit
```
### Track Egress not writing files
```bash
# Check egress logs
docker compose -f docker-compose.selfhosted.yml logs livekit-egress --tail 30
# Verify S3 credentials
# Egress receives S3 config per-request from the server, so check server/.env:
grep LIVEKIT_STORAGE server/.env
```

View File

@@ -170,6 +170,8 @@ These start regardless of which flags you pass:
| `ollama-cpu` | `ollama-cpu` | Local Ollama LLM on CPU | | `ollama-cpu` | `ollama-cpu` | Local Ollama LLM on CPU |
| `garage` | `garage` | Local S3-compatible object storage | | `garage` | `garage` | Local S3-compatible object storage |
| `caddy` | `caddy` | Reverse proxy with SSL | | `caddy` | `caddy` | Reverse proxy with SSL |
| `dailyco` | `hatchet-worker-cpu` | Hatchet workflow workers for Daily.co multitrack processing |
| `livekit` | `livekit-server`, `livekit-egress` | Self-hosted video platform + per-participant audio recording |
### The "transcription" Alias ### The "transcription" Alias
@@ -206,11 +208,17 @@ Both the `gpu` and `cpu` services define a Docker network alias of `transcriptio
│ :8000 │ └─────────┘ └─────────┘ │ :8000 │ └─────────┘ └─────────┘
└───────────┘ └───────────┘
┌─────┴─────┐ ┌─────────┐ ┌─────┴─────┐ ┌─────────┐ ┌──────────────┐
│ ollama │ │ garage │ │ ollama │ │ garage │ │livekit-server│
│(optional) │ │(optional│ │(optional) │ │(optional│ │ (optional) │
│ :11435 │ │ S3) │ │ :11435 │ │ S3) │ │ :7880 │
└───────────┘ └─────────┘ └───────────┘ └─────────┘ └──────┬───────┘
┌──────┴───────┐
│livekit-egress│
│ (Track Egress│
│ to S3) │
└──────────────┘
``` ```
### How Services Interact ### How Services Interact
@@ -320,7 +328,9 @@ You can point your own reverse proxy (nginx, Traefik, etc.) at these ports.
### WebRTC and UDP ### WebRTC and UDP
The server exposes UDP ports 50000-50100 for WebRTC ICE candidates. The `WEBRTC_HOST` variable tells the server which IP to advertise in ICE candidates — this must be the server's actual IP address (not a domain), because WebRTC uses UDP which doesn't go through the HTTP reverse proxy. The server exposes UDP ports 40000-40100 for Reflector's own WebRTC ICE candidates. When LiveKit is enabled, it additionally uses ports 44200-44300/udp for its WebRTC ICE candidates. The `WEBRTC_HOST` variable tells the server which IP to advertise in ICE candidates — this must be the server's actual IP address (not a domain), because WebRTC uses UDP which doesn't go through the HTTP reverse proxy.
Port ranges are chosen to avoid collision with macOS ephemeral ports (49152-65535).
--- ---
@@ -426,7 +436,10 @@ All services communicate over Docker's default bridge network. Only specific por
| 3903 | Garage | `0.0.0.0:3903` | Garage admin API | | 3903 | Garage | `0.0.0.0:3903` | Garage admin API |
| 8000 | GPU/CPU | `127.0.0.1:8000` | ML model API (localhost only) | | 8000 | GPU/CPU | `127.0.0.1:8000` | ML model API (localhost only) |
| 11435 | Ollama | `127.0.0.1:11435` | Ollama API (localhost only) | | 11435 | Ollama | `127.0.0.1:11435` | Ollama API (localhost only) |
| 50000-50100/udp | Server | `0.0.0.0:50000-50100` | WebRTC ICE candidates | | 40000-40100/udp | Server | `0.0.0.0:40000-40100` | Reflector WebRTC ICE candidates |
| 7880 | LiveKit | `0.0.0.0:7880` | LiveKit signaling (WS) |
| 7881 | LiveKit | `0.0.0.0:7881` | LiveKit RTC over TCP |
| 44200-44300/udp | LiveKit | `0.0.0.0:44200-44300` | LiveKit WebRTC ICE candidates |
Services bound to `127.0.0.1` are only accessible from the host itself (not from the network). Caddy is the only service exposed to the internet on standard HTTP/HTTPS ports. Services bound to `127.0.0.1` are only accessible from the host itself (not from the network). Caddy is the only service exposed to the internet on standard HTTP/HTTPS ports.
@@ -443,6 +456,8 @@ Inside the Docker network, services reach each other by their compose service na
| `transcription` | GPU or CPU container (network alias) | | `transcription` | GPU or CPU container (network alias) |
| `ollama` / `ollama-cpu` | Ollama container | | `ollama` / `ollama-cpu` | Ollama container |
| `garage` | Garage S3 container | | `garage` | Garage S3 container |
| `livekit-server` | LiveKit SFU server |
| `livekit-egress` | LiveKit Track Egress service |
--- ---

View File

@@ -144,6 +144,7 @@ Browse all available models at https://ollama.com/library.
| Flag | What it does | | Flag | What it does |
|------|-------------| |------|-------------|
| `--livekit` | Enables LiveKit self-hosted video platform. Generates API credentials, starts `livekit-server` + `livekit-egress`. See [LiveKit Setup](livekit-setup.md). |
| `--garage` | Starts Garage (local S3-compatible storage). Auto-configures bucket, keys, and env vars. | | `--garage` | Starts Garage (local S3-compatible storage). Auto-configures bucket, keys, and env vars. |
| `--caddy` | Starts Caddy reverse proxy on ports 80/443 with self-signed cert. | | `--caddy` | Starts Caddy reverse proxy on ports 80/443 with self-signed cert. |
| `--domain DOMAIN` | Use a real domain with Let's Encrypt auto-HTTPS (implies `--caddy`). Requires DNS A record pointing to this server and ports 80/443 open. | | `--domain DOMAIN` | Use a real domain with Let's Encrypt auto-HTTPS (implies `--caddy`). Requires DNS A record pointing to this server and ports 80/443 open. |
@@ -154,6 +155,20 @@ Without `--garage`, you **must** provide S3-compatible credentials (the script w
Without `--caddy` or `--domain`, no ports are exposed. Point your own reverse proxy at `web:3000` (frontend) and `server:1250` (API). Without `--caddy` or `--domain`, no ports are exposed. Point your own reverse proxy at `web:3000` (frontend) and `server:1250` (API).
## Video Platform (LiveKit)
For self-hosted video rooms with per-participant audio recording, add `--livekit` to your setup command:
```bash
./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy
```
This generates LiveKit API credentials, creates config files (`livekit.yaml`, `egress.yaml`), and starts `livekit-server` (WebRTC SFU) + `livekit-egress` (per-participant audio recording to S3). LiveKit reuses the same Redis and S3 storage as the rest of the stack.
New rooms default to LiveKit when `DEFAULT_VIDEO_PLATFORM=livekit` is set (done automatically by the setup script). Existing Daily.co and Whereby rooms continue to work. On re-runs, the script detects the existing `LIVEKIT_API_KEY` in `server/.env` automatically.
> For detailed configuration, environment variables, ports, and troubleshooting, see [LiveKit Setup](livekit-setup.md).
**Using a domain (recommended for production):** Point a DNS A record at your server's IP, then pass `--domain your.domain.com`. Caddy will automatically obtain and renew a Let's Encrypt certificate. Ports 80 and 443 must be open. **Using a domain (recommended for production):** Point a DNS A record at your server's IP, then pass `--domain your.domain.com`. Caddy will automatically obtain and renew a Let's Encrypt certificate. Ports 80 and 443 must be open.
**Without a domain:** `--caddy` alone uses a self-signed certificate. Browsers will show a security warning that must be accepted. **Without a domain:** `--caddy` alone uses a self-signed certificate. Browsers will show a security warning that must be accepted.

26
egress.yaml.example Normal file
View File

@@ -0,0 +1,26 @@
# LiveKit Egress configuration
# Generated by setup-selfhosted.sh — do not edit manually.
# See: https://docs.livekit.io/self-hosting/egress/
api_key: __LIVEKIT_API_KEY__
api_secret: __LIVEKIT_API_SECRET__
ws_url: ws://livekit-server:7880
redis:
address: redis:6379
# Health check
health_port: 7082
# Logging
log_level: info
# CPU cost limits (Track Egress only — no composite video)
# Track Egress costs 1.0 CPU unit per track; hundreds can run on one instance.
# Default max_cpu_utilization is 0.8 (80% of available cores).
# Session limits
session_limits:
file_output_max_duration: 4h # Max 4 hours per recording
# S3 storage is configured per-request via the API (not here).
# The server passes S3 credentials when starting each Track Egress.

34
livekit.yaml.example Normal file
View File

@@ -0,0 +1,34 @@
# LiveKit server configuration
# Generated by setup-selfhosted.sh — do not edit manually.
# See: https://docs.livekit.io/self-hosting/deployment/
port: 7880
rtc:
tcp_port: 7881
port_range_start: 44200
port_range_end: 44300
# use_external_ip: true # Uncomment for production with public IP
redis:
address: redis:6379
keys:
# API key : API secret (generated by setup script)
# devkey: secret
__LIVEKIT_API_KEY__: __LIVEKIT_API_SECRET__
webhook:
urls:
- http://server:1250/v1/livekit/webhook
api_key: __LIVEKIT_API_KEY__
logging:
level: info
# Room settings
room:
empty_timeout: 300 # 5 minutes after last participant leaves
max_participants: 0 # 0 = unlimited
# Track Egress only (no composite video)
# Egress is configured via egress.yaml on the egress service

View File

@@ -26,6 +26,8 @@
# (If omitted, configure an external OpenAI-compatible LLM in server/.env) # (If omitted, configure an external OpenAI-compatible LLM in server/.env)
# #
# Optional flags: # Optional flags:
# --livekit Enable LiveKit self-hosted video platform (generates credentials,
# starts livekit-server + livekit-egress containers)
# --garage Use Garage for local S3-compatible storage # --garage Use Garage for local S3-compatible storage
# --caddy Enable Caddy reverse proxy with auto-SSL # --caddy Enable Caddy reverse proxy with auto-SSL
# --domain DOMAIN Use a real domain for Caddy (enables Let's Encrypt auto-HTTPS) # --domain DOMAIN Use a real domain for Caddy (enables Let's Encrypt auto-HTTPS)
@@ -42,10 +44,10 @@
# --build Build backend and frontend images from source instead of pulling # --build Build backend and frontend images from source instead of pulling
# #
# Examples: # Examples:
# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy # ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy
# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy --domain reflector.example.com # ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy --domain reflector.example.com
# ./scripts/setup-selfhosted.sh --cpu --ollama-cpu --garage --caddy # ./scripts/setup-selfhosted.sh --cpu --ollama-cpu --livekit --garage --caddy
# ./scripts/setup-selfhosted.sh --hosted --garage --caddy # ./scripts/setup-selfhosted.sh --hosted --livekit --garage --caddy
# ./scripts/setup-selfhosted.sh --cpu --padding modal --garage --caddy # ./scripts/setup-selfhosted.sh --cpu --padding modal --garage --caddy
# ./scripts/setup-selfhosted.sh --gpu --translation passthrough --garage --caddy # ./scripts/setup-selfhosted.sh --gpu --translation passthrough --garage --caddy
# ./scripts/setup-selfhosted.sh --cpu --diarization modal --translation modal --garage # ./scripts/setup-selfhosted.sh --cpu --diarization modal --translation modal --garage
@@ -58,9 +60,11 @@
# Config memory: after a successful run, flags are saved to data/.selfhosted-last-args. # Config memory: after a successful run, flags are saved to data/.selfhosted-last-args.
# Re-running with no arguments replays the saved configuration automatically. # Re-running with no arguments replays the saved configuration automatically.
# #
# The script auto-detects Daily.co (DAILY_API_KEY) and Whereby (WHEREBY_API_KEY) # The script auto-detects Daily.co (DAILY_API_KEY), Whereby (WHEREBY_API_KEY),
# from server/.env. If Daily.co is configured, Hatchet workflow services are # and LiveKit (LIVEKIT_API_KEY) from server/.env.
# started automatically for multitrack recording processing. # - Daily.co: enables Hatchet workflow services for multitrack recording processing.
# - LiveKit: enables livekit-server + livekit-egress containers (self-hosted,
# generates livekit.yaml and egress.yaml configs automatically).
# #
# Idempotent — safe to re-run at any time. # Idempotent — safe to re-run at any time.
# #
@@ -207,6 +211,7 @@ fi
MODEL_MODE="" # gpu or cpu (required, mutually exclusive) MODEL_MODE="" # gpu or cpu (required, mutually exclusive)
OLLAMA_MODE="" # ollama-gpu or ollama-cpu (optional) OLLAMA_MODE="" # ollama-gpu or ollama-cpu (optional)
USE_GARAGE=false USE_GARAGE=false
USE_LIVEKIT=false
USE_CADDY=false USE_CADDY=false
CUSTOM_DOMAIN="" # optional domain for Let's Encrypt HTTPS CUSTOM_DOMAIN="" # optional domain for Let's Encrypt HTTPS
BUILD_IMAGES=false # build backend/frontend from source BUILD_IMAGES=false # build backend/frontend from source
@@ -261,6 +266,7 @@ for i in "${!ARGS[@]}"; do
OLLAMA_MODEL="${ARGS[$next_i]}" OLLAMA_MODEL="${ARGS[$next_i]}"
SKIP_NEXT=true ;; SKIP_NEXT=true ;;
--garage) USE_GARAGE=true ;; --garage) USE_GARAGE=true ;;
--livekit) USE_LIVEKIT=true ;;
--caddy) USE_CADDY=true ;; --caddy) USE_CADDY=true ;;
--build) BUILD_IMAGES=true ;; --build) BUILD_IMAGES=true ;;
--password) --password)
@@ -505,6 +511,113 @@ if [[ "$HAS_OVERRIDES" == "true" ]]; then
MODE_DISPLAY="$MODE_DISPLAY (overrides: transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION, translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN)" MODE_DISPLAY="$MODE_DISPLAY (overrides: transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION, translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN)"
fi fi
# =========================================================
# LiveKit config generation helper
# =========================================================
_generate_livekit_config() {
local lk_key lk_secret lk_url
lk_key=$(env_get "$SERVER_ENV" "LIVEKIT_API_KEY" || true)
lk_secret=$(env_get "$SERVER_ENV" "LIVEKIT_API_SECRET" || true)
lk_url=$(env_get "$SERVER_ENV" "LIVEKIT_URL" || true)
if [[ -z "$lk_key" ]] || [[ -z "$lk_secret" ]]; then
warn "LIVEKIT_API_KEY or LIVEKIT_API_SECRET not set — generating random credentials"
lk_key="reflector_$(openssl rand -hex 8)"
lk_secret="$(openssl rand -hex 32)"
env_set "$SERVER_ENV" "LIVEKIT_API_KEY" "$lk_key"
env_set "$SERVER_ENV" "LIVEKIT_API_SECRET" "$lk_secret"
env_set "$SERVER_ENV" "LIVEKIT_URL" "ws://livekit-server:7880"
ok "Generated LiveKit API credentials"
fi
# Set internal URL for server->livekit communication
if ! env_has_key "$SERVER_ENV" "LIVEKIT_URL" || [[ -z "$(env_get "$SERVER_ENV" "LIVEKIT_URL" || true)" ]]; then
env_set "$SERVER_ENV" "LIVEKIT_URL" "ws://livekit-server:7880"
fi
# Set public URL based on deployment mode.
# When Caddy is enabled (HTTPS), LiveKit WebSocket is proxied through Caddy
# at /lk-ws to avoid mixed-content blocking (browsers block ws:// on https:// pages).
# When no Caddy, browsers connect directly to LiveKit on port 7880.
local public_lk_url
if [[ "$USE_CADDY" == "true" ]]; then
if [[ -n "$CUSTOM_DOMAIN" ]]; then
public_lk_url="wss://${CUSTOM_DOMAIN}/lk-ws"
elif [[ -n "$PRIMARY_IP" ]]; then
public_lk_url="wss://${PRIMARY_IP}/lk-ws"
else
public_lk_url="wss://localhost/lk-ws"
fi
else
if [[ -n "$PRIMARY_IP" ]]; then
public_lk_url="ws://${PRIMARY_IP}:7880"
else
public_lk_url="ws://localhost:7880"
fi
fi
env_set "$SERVER_ENV" "LIVEKIT_PUBLIC_URL" "$public_lk_url"
env_set "$SERVER_ENV" "DEFAULT_VIDEO_PLATFORM" "livekit"
# LiveKit storage: reuse transcript storage credentials if not separately configured
if ! env_has_key "$SERVER_ENV" "LIVEKIT_STORAGE_AWS_BUCKET_NAME" || [[ -z "$(env_get "$SERVER_ENV" "LIVEKIT_STORAGE_AWS_BUCKET_NAME" || true)" ]]; then
local ts_bucket ts_region ts_key ts_secret ts_endpoint
ts_bucket=$(env_get "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_BUCKET_NAME" 2>/dev/null || echo "reflector-bucket")
ts_region=$(env_get "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_REGION" 2>/dev/null || echo "us-east-1")
ts_key=$(env_get "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID" 2>/dev/null || true)
ts_secret=$(env_get "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY" 2>/dev/null || true)
ts_endpoint=$(env_get "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL" 2>/dev/null || true)
env_set "$SERVER_ENV" "LIVEKIT_STORAGE_AWS_BUCKET_NAME" "$ts_bucket"
env_set "$SERVER_ENV" "LIVEKIT_STORAGE_AWS_REGION" "$ts_region"
[[ -n "$ts_key" ]] && env_set "$SERVER_ENV" "LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID" "$ts_key"
[[ -n "$ts_secret" ]] && env_set "$SERVER_ENV" "LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY" "$ts_secret"
[[ -n "$ts_endpoint" ]] && env_set "$SERVER_ENV" "LIVEKIT_STORAGE_AWS_ENDPOINT_URL" "$ts_endpoint"
if [[ -z "$ts_key" ]] || [[ -z "$ts_secret" ]]; then
warn "LiveKit storage: S3 credentials not found — Track Egress recording will fail!"
warn "Configure LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID and LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY in server/.env"
warn "Or run with --garage to auto-configure local S3 storage"
else
ok "LiveKit storage: reusing transcript storage config"
fi
fi
# Generate livekit.yaml
cat > "$ROOT_DIR/livekit.yaml" << LKEOF
port: 7880
rtc:
tcp_port: 7881
port_range_start: 44200
port_range_end: 44300
redis:
address: redis:6379
keys:
${lk_key}: ${lk_secret}
webhook:
urls:
- http://server:1250/v1/livekit/webhook
api_key: ${lk_key}
logging:
level: info
room:
empty_timeout: 300
max_participants: 0
LKEOF
ok "Generated livekit.yaml"
# Generate egress.yaml (Track Egress only — no composite video)
cat > "$ROOT_DIR/egress.yaml" << EGEOF
api_key: ${lk_key}
api_secret: ${lk_secret}
ws_url: ws://livekit-server:7880
redis:
address: redis:6379
health_port: 7082
log_level: info
session_limits:
file_output_max_duration: 4h
EGEOF
ok "Generated egress.yaml"
}
# ========================================================= # =========================================================
# Step 0: Prerequisites # Step 0: Prerequisites
# ========================================================= # =========================================================
@@ -1014,14 +1127,17 @@ step_www_env() {
fi fi
# Enable rooms if any video platform is configured in server/.env # Enable rooms if any video platform is configured in server/.env
local _daily_key="" _whereby_key="" local _daily_key="" _whereby_key="" _livekit_key=""
if env_has_key "$SERVER_ENV" "DAILY_API_KEY"; then if env_has_key "$SERVER_ENV" "DAILY_API_KEY"; then
_daily_key=$(env_get "$SERVER_ENV" "DAILY_API_KEY") _daily_key=$(env_get "$SERVER_ENV" "DAILY_API_KEY")
fi fi
if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY"; then if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY"; then
_whereby_key=$(env_get "$SERVER_ENV" "WHEREBY_API_KEY") _whereby_key=$(env_get "$SERVER_ENV" "WHEREBY_API_KEY")
fi fi
if [[ -n "$_daily_key" ]] || [[ -n "$_whereby_key" ]]; then if env_has_key "$SERVER_ENV" "LIVEKIT_API_KEY"; then
_livekit_key=$(env_get "$SERVER_ENV" "LIVEKIT_API_KEY")
fi
if [[ -n "$_daily_key" ]] || [[ -n "$_whereby_key" ]] || [[ -n "$_livekit_key" ]]; then
env_set "$WWW_ENV" "FEATURE_ROOMS" "true" env_set "$WWW_ENV" "FEATURE_ROOMS" "true"
ok "Rooms feature enabled (video platform configured)" ok "Rooms feature enabled (video platform configured)"
fi fi
@@ -1188,6 +1304,20 @@ step_caddyfile() {
rm -rf "$caddyfile" rm -rf "$caddyfile"
fi fi
# LiveKit reverse proxy snippet (inserted into Caddyfile when --livekit is active)
# LiveKit reverse proxy snippet (inserted into Caddyfile when --livekit is active).
# Strips /lk-ws prefix so LiveKit server sees requests at its root /.
local lk_proxy_block=""
if [[ "$LIVEKIT_DETECTED" == "true" ]]; then
lk_proxy_block="
handle_path /lk-ws/* {
reverse_proxy livekit-server:7880
}
handle_path /lk-ws {
reverse_proxy livekit-server:7880
}"
fi
if [[ -n "$TLS_CERT_PATH" ]] && [[ -n "$CUSTOM_DOMAIN" ]]; then if [[ -n "$TLS_CERT_PATH" ]] && [[ -n "$CUSTOM_DOMAIN" ]]; then
# Custom domain with user-provided TLS certificate (from --custom-ca directory) # Custom domain with user-provided TLS certificate (from --custom-ca directory)
cat > "$caddyfile" << CADDYEOF cat > "$caddyfile" << CADDYEOF
@@ -1199,7 +1329,7 @@ $CUSTOM_DOMAIN {
} }
handle /health { handle /health {
reverse_proxy server:1250 reverse_proxy server:1250
} }${lk_proxy_block}
handle { handle {
reverse_proxy web:3000 reverse_proxy web:3000
} }
@@ -1216,7 +1346,7 @@ $CUSTOM_DOMAIN {
} }
handle /health { handle /health {
reverse_proxy server:1250 reverse_proxy server:1250
} }${lk_proxy_block}
handle { handle {
reverse_proxy web:3000 reverse_proxy web:3000
} }
@@ -1235,7 +1365,7 @@ CADDYEOF
} }
handle /health { handle /health {
reverse_proxy server:1250 reverse_proxy server:1250
} }${lk_proxy_block}
handle { handle {
reverse_proxy web:3000 reverse_proxy web:3000
} }
@@ -1621,14 +1751,21 @@ main() {
# Auto-detect video platforms from server/.env (after step_server_env so file exists) # Auto-detect video platforms from server/.env (after step_server_env so file exists)
DAILY_DETECTED=false DAILY_DETECTED=false
WHEREBY_DETECTED=false WHEREBY_DETECTED=false
LIVEKIT_DETECTED=false
if env_has_key "$SERVER_ENV" "DAILY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "DAILY_API_KEY")" ]]; then if env_has_key "$SERVER_ENV" "DAILY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "DAILY_API_KEY")" ]]; then
DAILY_DETECTED=true DAILY_DETECTED=true
fi fi
if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "WHEREBY_API_KEY")" ]]; then if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "WHEREBY_API_KEY")" ]]; then
WHEREBY_DETECTED=true WHEREBY_DETECTED=true
fi fi
# LiveKit: enabled via --livekit flag OR pre-existing LIVEKIT_API_KEY in env
if [[ "$USE_LIVEKIT" == "true" ]]; then
LIVEKIT_DETECTED=true
elif env_has_key "$SERVER_ENV" "LIVEKIT_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "LIVEKIT_API_KEY")" ]]; then
LIVEKIT_DETECTED=true
fi
ANY_PLATFORM_DETECTED=false ANY_PLATFORM_DETECTED=false
[[ "$DAILY_DETECTED" == "true" || "$WHEREBY_DETECTED" == "true" ]] && ANY_PLATFORM_DETECTED=true [[ "$DAILY_DETECTED" == "true" || "$WHEREBY_DETECTED" == "true" || "$LIVEKIT_DETECTED" == "true" ]] && ANY_PLATFORM_DETECTED=true
# Conditional profile activation for Daily.co # Conditional profile activation for Daily.co
if [[ "$DAILY_DETECTED" == "true" ]]; then if [[ "$DAILY_DETECTED" == "true" ]]; then
@@ -1636,6 +1773,13 @@ main() {
ok "Daily.co detected — enabling Hatchet workflow services" ok "Daily.co detected — enabling Hatchet workflow services"
fi fi
# Conditional profile activation for LiveKit
if [[ "$LIVEKIT_DETECTED" == "true" ]]; then
COMPOSE_PROFILES+=("livekit")
_generate_livekit_config
ok "LiveKit enabled — livekit-server + livekit-egress"
fi
# Generate .env.hatchet for hatchet dashboard config (always needed) # Generate .env.hatchet for hatchet dashboard config (always needed)
local hatchet_server_url hatchet_cookie_domain local hatchet_server_url hatchet_cookie_domain
if [[ -n "$CUSTOM_DOMAIN" ]]; then if [[ -n "$CUSTOM_DOMAIN" ]]; then
@@ -1702,6 +1846,7 @@ EOF
[[ "$USES_OLLAMA" != "true" ]] && echo " LLM: External (configure in server/.env)" [[ "$USES_OLLAMA" != "true" ]] && echo " LLM: External (configure in server/.env)"
[[ "$DAILY_DETECTED" == "true" ]] && echo " Video: Daily.co (live rooms + multitrack processing via Hatchet)" [[ "$DAILY_DETECTED" == "true" ]] && echo " Video: Daily.co (live rooms + multitrack processing via Hatchet)"
[[ "$WHEREBY_DETECTED" == "true" ]] && echo " Video: Whereby (live rooms)" [[ "$WHEREBY_DETECTED" == "true" ]] && echo " Video: Whereby (live rooms)"
[[ "$LIVEKIT_DETECTED" == "true" ]] && echo " Video: LiveKit (self-hosted, live rooms + track egress)"
[[ "$ANY_PLATFORM_DETECTED" != "true" ]] && echo " Video: None (rooms disabled)" [[ "$ANY_PLATFORM_DETECTED" != "true" ]] && echo " Video: None (rooms disabled)"
if [[ "$USE_CUSTOM_CA" == "true" ]]; then if [[ "$USE_CUSTOM_CA" == "true" ]]; then
echo " CA: Custom (certs/ca.crt)" echo " CA: Custom (certs/ca.crt)"

View File

@@ -42,6 +42,7 @@ dependencies = [
"pydantic>=2.12.5", "pydantic>=2.12.5",
"aiosmtplib>=3.0.0", "aiosmtplib>=3.0.0",
"email-validator>=2.0.0", "email-validator>=2.0.0",
"livekit-api>=1.1.0",
] ]
[dependency-groups] [dependency-groups]

View File

@@ -15,6 +15,7 @@ from reflector.metrics import metrics_init
from reflector.settings import settings from reflector.settings import settings
from reflector.views.config import router as config_router from reflector.views.config import router as config_router
from reflector.views.daily import router as daily_router from reflector.views.daily import router as daily_router
from reflector.views.livekit import router as livekit_router
from reflector.views.meetings import router as meetings_router from reflector.views.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router from reflector.views.rooms import router as rooms_router
from reflector.views.rtc_offer import router as rtc_offer_router from reflector.views.rtc_offer import router as rtc_offer_router
@@ -112,6 +113,7 @@ app.include_router(config_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1") app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1") app.include_router(whereby_router, prefix="/v1")
app.include_router(daily_router, prefix="/v1/daily") app.include_router(daily_router, prefix="/v1/daily")
app.include_router(livekit_router, prefix="/v1/livekit")
if auth_router: if auth_router:
app.include_router(auth_router, prefix="/v1") app.include_router(auth_router, prefix="/v1")
add_pagination(app) add_pagination(app)

View File

@@ -0,0 +1,12 @@
"""
LiveKit API Module — thin wrapper around the livekit-api SDK.
"""
from .client import LiveKitApiClient
from .webhooks import create_webhook_receiver, verify_webhook
__all__ = [
"LiveKitApiClient",
"create_webhook_receiver",
"verify_webhook",
]

View File

@@ -0,0 +1,177 @@
"""
LiveKit API client wrapping the official livekit-api Python SDK.
Handles room management, access tokens, and Track Egress for
per-participant audio recording to S3-compatible storage.
"""
from datetime import timedelta
from livekit.api import (
AccessToken,
CreateRoomRequest,
DeleteRoomRequest,
DirectFileOutput,
EgressInfo,
ListEgressRequest,
ListParticipantsRequest,
LiveKitAPI,
Room,
S3Upload,
StopEgressRequest,
TrackEgressRequest,
VideoGrants,
)
class LiveKitApiClient:
"""Thin wrapper around LiveKitAPI for Reflector's needs."""
def __init__(
self,
url: str,
api_key: str,
api_secret: str,
s3_bucket: str | None = None,
s3_region: str | None = None,
s3_access_key: str | None = None,
s3_secret_key: str | None = None,
s3_endpoint: str | None = None,
):
self._url = url
self._api_key = api_key
self._api_secret = api_secret
self._s3_bucket = s3_bucket
self._s3_region = s3_region or "us-east-1"
self._s3_access_key = s3_access_key
self._s3_secret_key = s3_secret_key
self._s3_endpoint = s3_endpoint
self._api = LiveKitAPI(url=url, api_key=api_key, api_secret=api_secret)
# ── Room management ──────────────────────────────────────────
async def create_room(
self,
name: str,
empty_timeout: int = 300,
max_participants: int = 0,
) -> Room:
"""Create a LiveKit room.
Args:
name: Room name (unique identifier).
empty_timeout: Seconds to keep room alive after last participant leaves.
max_participants: 0 = unlimited.
"""
req = CreateRoomRequest(
name=name,
empty_timeout=empty_timeout,
max_participants=max_participants,
)
return await self._api.room.create_room(req)
async def delete_room(self, room_name: str) -> None:
await self._api.room.delete_room(DeleteRoomRequest(room=room_name))
async def list_participants(self, room_name: str):
resp = await self._api.room.list_participants(
ListParticipantsRequest(room=room_name)
)
return resp.participants
# ── Access tokens ────────────────────────────────────────────
def create_access_token(
self,
room_name: str,
participant_identity: str,
participant_name: str | None = None,
can_publish: bool = True,
can_subscribe: bool = True,
room_admin: bool = False,
ttl_seconds: int = 86400,
) -> str:
"""Generate a JWT access token for a participant."""
token = AccessToken(
api_key=self._api_key,
api_secret=self._api_secret,
)
token.identity = participant_identity
token.name = participant_name or participant_identity
token.ttl = timedelta(seconds=ttl_seconds)
token.with_grants(
VideoGrants(
room_join=True,
room=room_name,
can_publish=can_publish,
can_subscribe=can_subscribe,
room_admin=room_admin,
)
)
return token.to_jwt()
# ── Track Egress (per-participant audio recording) ───────────
def _build_s3_upload(self) -> S3Upload:
"""Build S3Upload config for egress output."""
if not all([self._s3_bucket, self._s3_access_key, self._s3_secret_key]):
raise ValueError(
"S3 storage not configured for LiveKit egress. "
"Set LIVEKIT_STORAGE_AWS_* environment variables."
)
kwargs = {
"access_key": self._s3_access_key,
"secret": self._s3_secret_key,
"bucket": self._s3_bucket,
"region": self._s3_region,
"force_path_style": True, # Required for Garage/MinIO
}
if self._s3_endpoint:
kwargs["endpoint"] = self._s3_endpoint
return S3Upload(**kwargs)
async def start_track_egress(
self,
room_name: str,
track_sid: str,
s3_filepath: str,
) -> EgressInfo:
"""Start Track Egress for a single audio track (writes OGG/Opus to S3).
Args:
room_name: LiveKit room name.
track_sid: Track SID to record.
s3_filepath: S3 key path for the output file.
"""
req = TrackEgressRequest(
room_name=room_name,
track_id=track_sid,
file=DirectFileOutput(
filepath=s3_filepath,
s3=self._build_s3_upload(),
),
)
return await self._api.egress.start_track_egress(req)
async def list_egress(self, room_name: str | None = None) -> list[EgressInfo]:
req = ListEgressRequest()
if room_name:
req.room_name = room_name
resp = await self._api.egress.list_egress(req)
return list(resp.items)
async def stop_egress(self, egress_id: str) -> EgressInfo:
return await self._api.egress.stop_egress(
StopEgressRequest(egress_id=egress_id)
)
# ── Cleanup ──────────────────────────────────────────────────
async def close(self):
await self._api.aclose()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View File

@@ -0,0 +1,35 @@
"""
LiveKit webhook verification and event parsing.
LiveKit signs webhooks using the API secret as a JWT.
The WebhookReceiver from the SDK handles verification.
"""
from livekit.api import TokenVerifier, WebhookEvent, WebhookReceiver
from reflector.logger import logger
def create_webhook_receiver(api_key: str, api_secret: str) -> WebhookReceiver:
"""Create a WebhookReceiver for verifying LiveKit webhook signatures."""
return WebhookReceiver(
token_verifier=TokenVerifier(api_key=api_key, api_secret=api_secret)
)
def verify_webhook(
receiver: WebhookReceiver,
body: str | bytes,
auth_header: str,
) -> WebhookEvent | None:
"""Verify and parse a LiveKit webhook event.
Returns the parsed WebhookEvent if valid, None if verification fails.
"""
if isinstance(body, bytes):
body = body.decode("utf-8")
try:
return receiver.receive(body, auth_header)
except Exception as e:
logger.warning("LiveKit webhook verification failed", error=str(e))
return None

View File

@@ -1,5 +1,6 @@
from typing import Literal from typing import Literal
Platform = Literal["whereby", "daily"] Platform = Literal["whereby", "daily", "livekit"]
WHEREBY_PLATFORM: Platform = "whereby" WHEREBY_PLATFORM: Platform = "whereby"
DAILY_PLATFORM: Platform = "daily" DAILY_PLATFORM: Platform = "daily"
LIVEKIT_PLATFORM: Platform = "livekit"

View File

@@ -195,6 +195,23 @@ class Settings(BaseSettings):
DAILY_WEBHOOK_UUID: str | None = ( DAILY_WEBHOOK_UUID: str | None = (
None # Webhook UUID for this environment. Not used by production code None # Webhook UUID for this environment. Not used by production code
) )
# LiveKit integration (self-hosted open-source video platform)
LIVEKIT_URL: str | None = (
None # e.g. ws://livekit:7880 (internal) or wss://livekit.example.com
)
LIVEKIT_API_KEY: str | None = None
LIVEKIT_API_SECRET: str | None = None
LIVEKIT_WEBHOOK_SECRET: str | None = None # Defaults to API_SECRET if not set
# LiveKit egress S3 storage (Track Egress writes per-participant audio here)
LIVEKIT_STORAGE_AWS_BUCKET_NAME: str | None = None
LIVEKIT_STORAGE_AWS_REGION: str | None = None
LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
LIVEKIT_STORAGE_AWS_ENDPOINT_URL: str | None = None # For Garage/MinIO
# Public URL for LiveKit (used in frontend room_url, e.g. wss://livekit.example.com)
LIVEKIT_PUBLIC_URL: str | None = None
# Platform Configuration # Platform Configuration
DEFAULT_VIDEO_PLATFORM: Platform = DAILY_PLATFORM DEFAULT_VIDEO_PLATFORM: Platform = DAILY_PLATFORM

View File

@@ -57,6 +57,22 @@ def get_source_storage(platform: str) -> Storage:
aws_secret_access_key=settings.WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY, aws_secret_access_key=settings.WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY,
) )
elif platform == "livekit":
if (
settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID
and settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY
and settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME
):
from reflector.storage.storage_aws import AwsStorage
return AwsStorage(
aws_bucket_name=settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME,
aws_region=settings.LIVEKIT_STORAGE_AWS_REGION or "us-east-1",
aws_access_key_id=settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID,
aws_secret_access_key=settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY,
aws_endpoint_url=settings.LIVEKIT_STORAGE_AWS_ENDPOINT_URL,
)
return get_transcripts_storage() return get_transcripts_storage()

View File

@@ -1,7 +1,7 @@
from reflector.settings import settings from reflector.settings import settings
from reflector.storage import get_dailyco_storage, get_whereby_storage from reflector.storage import get_dailyco_storage, get_whereby_storage
from ..schemas.platform import WHEREBY_PLATFORM, Platform from ..schemas.platform import LIVEKIT_PLATFORM, WHEREBY_PLATFORM, Platform
from .base import VideoPlatformClient, VideoPlatformConfig from .base import VideoPlatformClient, VideoPlatformConfig
from .registry import get_platform_client from .registry import get_platform_client
@@ -44,6 +44,27 @@ def get_platform_config(platform: Platform) -> VideoPlatformConfig:
s3_region=daily_storage.region, s3_region=daily_storage.region,
aws_role_arn=daily_storage.role_credential, aws_role_arn=daily_storage.role_credential,
) )
elif platform == LIVEKIT_PLATFORM:
if not settings.LIVEKIT_URL:
raise ValueError(
"LIVEKIT_URL is required when platform='livekit'. "
"Set LIVEKIT_URL environment variable."
)
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
raise ValueError(
"LIVEKIT_API_KEY and LIVEKIT_API_SECRET are required when platform='livekit'. "
"Set LIVEKIT_API_KEY and LIVEKIT_API_SECRET environment variables."
)
return VideoPlatformConfig(
api_key=settings.LIVEKIT_API_KEY,
webhook_secret=settings.LIVEKIT_WEBHOOK_SECRET
or settings.LIVEKIT_API_SECRET,
api_url=settings.LIVEKIT_URL,
s3_bucket=settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME,
s3_region=settings.LIVEKIT_STORAGE_AWS_REGION,
aws_access_key_id=settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID,
aws_access_key_secret=settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY,
)
else: else:
raise ValueError(f"Unknown platform: {platform}") raise ValueError(f"Unknown platform: {platform}")

View File

@@ -0,0 +1,175 @@
"""
LiveKit video platform client for Reflector.
Self-hosted, open-source alternative to Daily.co.
Uses Track Egress for per-participant audio recording (no composite video).
"""
from datetime import datetime, timezone
from urllib.parse import urlencode
from uuid import uuid4
from reflector.db.rooms import Room
from reflector.livekit_api.client import LiveKitApiClient
from reflector.livekit_api.webhooks import create_webhook_receiver, verify_webhook
from reflector.logger import logger
from reflector.settings import settings
from ..schemas.platform import Platform
from ..utils.string import NonEmptyString
from .base import ROOM_PREFIX_SEPARATOR, VideoPlatformClient
from .models import MeetingData, SessionData, VideoPlatformConfig
class LiveKitClient(VideoPlatformClient):
PLATFORM_NAME: Platform = "livekit"
TIMESTAMP_FORMAT = "%Y%m%d%H%M%S"
def __init__(self, config: VideoPlatformConfig):
super().__init__(config)
self._api_client = LiveKitApiClient(
url=config.api_url or "",
api_key=config.api_key,
api_secret=config.webhook_secret, # LiveKit uses API secret for both auth and webhooks
s3_bucket=config.s3_bucket,
s3_region=config.s3_region,
s3_access_key=config.aws_access_key_id,
s3_secret_key=config.aws_access_key_secret,
s3_endpoint=settings.LIVEKIT_STORAGE_AWS_ENDPOINT_URL,
)
self._webhook_receiver = create_webhook_receiver(
api_key=config.api_key,
api_secret=config.webhook_secret,
)
async def create_meeting(
self, room_name_prefix: NonEmptyString, end_date: datetime, room: Room
) -> MeetingData:
"""Create a LiveKit room for this meeting.
LiveKit rooms are created explicitly via API. A new room is created
for each Reflector meeting (same pattern as Daily.co).
"""
now = datetime.now(timezone.utc)
timestamp = now.strftime(self.TIMESTAMP_FORMAT)
room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{timestamp}"
# Calculate empty_timeout from end_date (seconds until expiry)
# Ensure end_date is timezone-aware for subtraction
end_date_aware = (
end_date if end_date.tzinfo else end_date.replace(tzinfo=timezone.utc)
)
remaining = int((end_date_aware - now).total_seconds())
empty_timeout = max(300, min(remaining, 86400)) # 5 min to 24 hours
lk_room = await self._api_client.create_room(
name=room_name,
empty_timeout=empty_timeout,
)
logger.info(
"LiveKit room created",
room_name=lk_room.name,
room_sid=lk_room.sid,
empty_timeout=empty_timeout,
)
# room_url includes the server URL + room name as query param.
# The join endpoint in rooms.py appends the token as another query param.
# Frontend parses: ws://host:7880?room=<name>&token=<jwt>
public_url = settings.LIVEKIT_PUBLIC_URL or settings.LIVEKIT_URL or ""
room_url = f"{public_url}?{urlencode({'room': lk_room.name})}"
return MeetingData(
meeting_id=lk_room.sid or str(uuid4()),
room_name=lk_room.name,
room_url=room_url,
host_room_url=room_url,
platform=self.PLATFORM_NAME,
extra_data={"livekit_room_sid": lk_room.sid},
)
async def get_room_sessions(self, room_name: str) -> list[SessionData]:
"""Get current participants in a LiveKit room.
For historical sessions, we rely on webhook-stored data (same as Daily).
This returns currently-connected participants.
"""
try:
participants = await self._api_client.list_participants(room_name)
return [
SessionData(
session_id=p.sid,
started_at=datetime.fromtimestamp(
p.joined_at if p.joined_at else 0, tz=timezone.utc
),
ended_at=None, # Still active
)
for p in participants
if p.sid # Skip empty entries
]
except Exception as e:
logger.debug(
"Could not list LiveKit participants (room may not exist)",
room_name=room_name,
error=str(e),
)
return []
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
# LiveKit doesn't have a logo upload concept; handled in frontend theming
return True
def verify_webhook_signature(
self, body: bytes, signature: str, timestamp: str | None = None
) -> bool:
"""Verify LiveKit webhook signature.
LiveKit sends the JWT in the Authorization header. The `signature`
param here receives the Authorization header value.
"""
event = verify_webhook(self._webhook_receiver, body, signature)
return event is not None
def create_access_token(
self,
room_name: str,
participant_identity: str,
participant_name: str | None = None,
is_admin: bool = False,
) -> str:
"""Generate a LiveKit access token for a participant."""
return self._api_client.create_access_token(
room_name=room_name,
participant_identity=participant_identity,
participant_name=participant_name,
room_admin=is_admin,
)
async def start_track_egress(
self,
room_name: str,
track_sid: str,
s3_filepath: str,
):
"""Start Track Egress for a single audio track."""
return await self._api_client.start_track_egress(
room_name=room_name,
track_sid=track_sid,
s3_filepath=s3_filepath,
)
async def list_egress(self, room_name: str | None = None):
return await self._api_client.list_egress(room_name=room_name)
async def stop_egress(self, egress_id: str):
return await self._api_client.stop_egress(egress_id=egress_id)
async def close(self):
await self._api_client.close()
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.close()

View File

@@ -1,6 +1,11 @@
from typing import Dict, Type from typing import Dict, Type
from ..schemas.platform import DAILY_PLATFORM, WHEREBY_PLATFORM, Platform from ..schemas.platform import (
DAILY_PLATFORM,
LIVEKIT_PLATFORM,
WHEREBY_PLATFORM,
Platform,
)
from .base import VideoPlatformClient, VideoPlatformConfig from .base import VideoPlatformClient, VideoPlatformConfig
_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {} _PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {}
@@ -26,10 +31,12 @@ def get_available_platforms() -> list[Platform]:
def _register_builtin_platforms(): def _register_builtin_platforms():
from .daily import DailyClient # noqa: PLC0415 from .daily import DailyClient # noqa: PLC0415
from .livekit import LiveKitClient # noqa: PLC0415
from .whereby import WherebyClient # noqa: PLC0415 from .whereby import WherebyClient # noqa: PLC0415
register_platform(WHEREBY_PLATFORM, WherebyClient) register_platform(WHEREBY_PLATFORM, WherebyClient)
register_platform(DAILY_PLATFORM, DailyClient) register_platform(DAILY_PLATFORM, DailyClient)
register_platform(LIVEKIT_PLATFORM, LiveKitClient)
_register_builtin_platforms() _register_builtin_platforms()

View File

@@ -0,0 +1,190 @@
"""LiveKit webhook handler.
Processes LiveKit webhook events for participant tracking and
Track Egress recording completion.
LiveKit sends webhooks as POST requests with JWT authentication
in the Authorization header.
"""
from fastapi import APIRouter, HTTPException, Request
from reflector.db.meetings import meetings_controller
from reflector.livekit_api.webhooks import create_webhook_receiver, verify_webhook
from reflector.logger import logger as _logger
from reflector.settings import settings
router = APIRouter()
logger = _logger.bind(platform="livekit")
# Module-level receiver, lazily initialized on first webhook
_webhook_receiver = None
def _get_webhook_receiver():
global _webhook_receiver
if _webhook_receiver is None:
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
raise ValueError("LiveKit not configured")
_webhook_receiver = create_webhook_receiver(
api_key=settings.LIVEKIT_API_KEY,
api_secret=settings.LIVEKIT_WEBHOOK_SECRET or settings.LIVEKIT_API_SECRET,
)
return _webhook_receiver
@router.post("/webhook")
async def livekit_webhook(request: Request):
"""Handle LiveKit webhook events.
LiveKit webhook events include:
- participant_joined / participant_left
- egress_started / egress_updated / egress_ended
- room_started / room_finished
- track_published / track_unpublished
"""
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
raise HTTPException(status_code=500, detail="LiveKit not configured")
body = await request.body()
auth_header = request.headers.get("Authorization", "")
receiver = _get_webhook_receiver()
event = verify_webhook(receiver, body, auth_header)
if event is None:
logger.warning(
"Invalid LiveKit webhook signature",
has_auth=bool(auth_header),
has_body=bool(body),
)
raise HTTPException(status_code=401, detail="Invalid webhook signature")
event_type = event.event
match event_type:
case "participant_joined":
await _handle_participant_joined(event)
case "participant_left":
await _handle_participant_left(event)
case "egress_started":
await _handle_egress_started(event)
case "egress_ended":
await _handle_egress_ended(event)
case "room_started":
logger.info(
"Room started",
room_name=event.room.name if event.room else None,
)
case "room_finished":
logger.info(
"Room finished",
room_name=event.room.name if event.room else None,
)
case "track_published" | "track_unpublished":
logger.debug(
f"Track event: {event_type}",
room_name=event.room.name if event.room else None,
participant=event.participant.identity if event.participant else None,
)
case _:
logger.debug(
"Unhandled LiveKit webhook event",
event_type=event_type,
)
return {"status": "ok"}
async def _handle_participant_joined(event):
room_name = event.room.name if event.room else None
participant = event.participant
if not room_name or not participant:
logger.warning("participant_joined: missing room or participant data")
return
meeting = await meetings_controller.get_by_room_name(room_name)
if not meeting:
logger.warning("participant_joined: meeting not found", room_name=room_name)
return
logger.info(
"Participant joined",
meeting_id=meeting.id,
room_name=room_name,
participant_identity=participant.identity,
participant_sid=participant.sid,
)
async def _handle_participant_left(event):
room_name = event.room.name if event.room else None
participant = event.participant
if not room_name or not participant:
logger.warning("participant_left: missing room or participant data")
return
meeting = await meetings_controller.get_by_room_name(room_name)
if not meeting:
logger.warning("participant_left: meeting not found", room_name=room_name)
return
logger.info(
"Participant left",
meeting_id=meeting.id,
room_name=room_name,
participant_identity=participant.identity,
participant_sid=participant.sid,
)
async def _handle_egress_started(event):
egress = event.egress_info
room_name = egress.room_name if egress else None
logger.info(
"Egress started",
room_name=room_name,
egress_id=egress.egress_id if egress else None,
)
async def _handle_egress_ended(event):
"""Handle Track Egress completion — trigger multitrack processing."""
egress = event.egress_info
if not egress:
logger.warning("egress_ended: no egress info in payload")
return
room_name = egress.room_name
# Check egress status
# EGRESS_COMPLETE = 3, EGRESS_FAILED = 4
status = egress.status
if status == 4: # EGRESS_FAILED
logger.error(
"Egress failed",
room_name=room_name,
egress_id=egress.egress_id,
error=egress.error,
)
return
# Extract output file info from egress results
file_results = list(egress.file_results)
logger.info(
"Egress ended",
room_name=room_name,
egress_id=egress.egress_id,
status=status,
num_files=len(file_results),
filenames=[f.filename for f in file_results] if file_results else [],
)
# Track Egress produces one file per egress request.
# The multitrack pipeline will be triggered separately once all tracks
# for a room are collected (via periodic polling or explicit trigger).
# TODO: Implement track collection and pipeline trigger

View File

@@ -598,4 +598,22 @@ async def rooms_join_meeting(
meeting = meeting.model_copy() meeting = meeting.model_copy()
meeting.room_url = add_query_param(meeting.room_url, "t", token) meeting.room_url = add_query_param(meeting.room_url, "t", token)
elif meeting.platform == "livekit":
client = create_platform_client(meeting.platform)
participant_identity = user_id or f"anon-{meeting_id[:8]}"
participant_name = (
getattr(user, "name", None) or participant_identity
if user
else participant_identity
)
token = client.create_access_token(
room_name=meeting.room_name,
participant_identity=participant_identity,
participant_name=participant_name,
is_admin=user_id == room.user_id if user_id else False,
)
meeting = meeting.model_copy()
# For LiveKit, room_url is the WS URL; token goes as a query param
meeting.room_url = add_query_param(meeting.room_url, "token", token)
return meeting return meeting

View File

@@ -83,7 +83,11 @@ def build_beat_schedule(
else: else:
logger.info("Daily.co beat tasks disabled (no DAILY_API_KEY)") logger.info("Daily.co beat tasks disabled (no DAILY_API_KEY)")
_any_platform = _whereby_enabled or _daily_enabled _livekit_enabled = bool(settings.LIVEKIT_API_KEY and settings.LIVEKIT_URL)
if _livekit_enabled:
logger.info("LiveKit platform detected")
_any_platform = _whereby_enabled or _daily_enabled or _livekit_enabled
if _any_platform: if _any_platform:
beat_schedule["process_meetings"] = { beat_schedule["process_meetings"] = {
"task": "reflector.worker.process.process_meetings", "task": "reflector.worker.process.process_meetings",

40
server/uv.lock generated
View File

@@ -1805,6 +1805,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" }, { url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" },
] ]
[[package]]
name = "livekit-api"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "livekit-protocol" },
{ name = "protobuf" },
{ name = "pyjwt" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b5/0a/ad3cce124e608c056d6390244ec4dd18c8a4b5f055693a95831da2119af7/livekit_api-1.1.0.tar.gz", hash = "sha256:f94c000534d3a9b506e6aed2f35eb88db1b23bdea33bb322f0144c4e9f73934e", size = 16649, upload-time = "2025-12-02T19:37:11.452Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/b9/8d8515e3e0e629ab07d399cf858b8fc7e0a02bbf6384a6592b285264b4b9/livekit_api-1.1.0-py3-none-any.whl", hash = "sha256:bfc1c2c65392eb3f580a2c28108269f0e79873f053578a677eee7bb1de8aa8fb", size = 19620, upload-time = "2025-12-02T19:37:10.075Z" },
]
[[package]]
name = "livekit-protocol"
version = "1.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
{ name = "types-protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/ca/d15e2a2cc8c8aa4ba621fe5f9ffd1806d88ac91c7b8fa4c09a3c0304dd92/livekit_protocol-1.1.3.tar.gz", hash = "sha256:cb4948d2513e81d91583f4a795bf80faa9026cedda509c5714999c7e33564287", size = 88746, upload-time = "2026-03-18T05:25:43.562Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/0e/f3d3e48628294df4559cffd0f8e1adf030127029e5a8da9beff9979090a0/livekit_protocol-1.1.3-py3-none-any.whl", hash = "sha256:fdae5640e064ab6549ec3d62d8bac75a3ef44d7ea73716069b419cbe8b360a5c", size = 107498, upload-time = "2026-03-18T05:25:42.077Z" },
]
[[package]] [[package]]
name = "llama-cloud" name = "llama-cloud"
version = "0.1.35" version = "0.1.35"
@@ -3364,6 +3393,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "icalendar" }, { name = "icalendar" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "livekit-api" },
{ name = "llama-index" }, { name = "llama-index" },
{ name = "llama-index-llms-openai-like" }, { name = "llama-index-llms-openai-like" },
{ name = "openai" }, { name = "openai" },
@@ -3445,6 +3475,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.24.1" }, { name = "httpx", specifier = ">=0.24.1" },
{ name = "icalendar", specifier = ">=6.0.0" }, { name = "icalendar", specifier = ">=6.0.0" },
{ name = "jsonschema", specifier = ">=4.23.0" }, { name = "jsonschema", specifier = ">=4.23.0" },
{ name = "livekit-api", specifier = ">=1.1.0" },
{ name = "llama-index", specifier = ">=0.12.52" }, { name = "llama-index", specifier = ">=0.12.52" },
{ name = "llama-index-llms-openai-like", specifier = ">=0.4.0" }, { name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },
{ name = "openai", specifier = ">=1.59.7" }, { name = "openai", specifier = ">=1.59.7" },
@@ -4399,6 +4430,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" }, { url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
] ]
[[package]]
name = "types-protobuf"
version = "6.32.1.20260221"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.14.1" version = "4.14.1"

View File

@@ -74,6 +74,7 @@ const recordingTypeOptions: SelectOption[] = [
const platformOptions: SelectOption[] = [ const platformOptions: SelectOption[] = [
{ label: "Whereby", value: "whereby" }, { label: "Whereby", value: "whereby" },
{ label: "Daily", value: "daily" }, { label: "Daily", value: "daily" },
{ label: "LiveKit", value: "livekit" },
]; ];
const roomInitialState = { const roomInitialState = {
@@ -309,10 +310,7 @@ export default function RoomsList() {
return; return;
} }
const platform: "whereby" | "daily" = const platform = room.platform as "whereby" | "daily" | "livekit";
room.platform === "whereby" || room.platform === "daily"
? room.platform
: "daily";
const roomData = { const roomData = {
name: room.name, name: room.name,
@@ -544,7 +542,10 @@ export default function RoomsList() {
<Select.Root <Select.Root
value={[room.platform]} value={[room.platform]}
onValueChange={(e) => { onValueChange={(e) => {
const newPlatform = e.value[0] as "whereby" | "daily"; const newPlatform = e.value[0] as
| "whereby"
| "daily"
| "livekit";
const updates: Partial<typeof room> = { const updates: Partial<typeof room> = {
platform: newPlatform, platform: newPlatform,
}; };

View File

@@ -0,0 +1,212 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Box, Spinner, Center, Text, IconButton } from "@chakra-ui/react";
import { useRouter, useParams } from "next/navigation";
import {
LiveKitRoom as LKRoom,
VideoConference,
RoomAudioRenderer,
} from "@livekit/components-react";
// LiveKit component styles — imported in the global layout to avoid
// Next.js CSS import restrictions in client components.
// See: app/[roomName]/layout.tsx
import type { components } from "../../reflector-api";
import { useAuth } from "../../lib/AuthProvider";
import { useRoomJoinMeeting } from "../../lib/apiHooks";
import { assertMeetingId } from "../../lib/types";
import {
ConsentDialogButton,
RecordingIndicator,
useConsentDialog,
} from "../../lib/consent";
import { useEmailTranscriptDialog } from "../../lib/emailTranscript";
import { featureEnabled } from "../../lib/features";
import { LuMail } from "react-icons/lu";
type Meeting = components["schemas"]["Meeting"];
type Room = components["schemas"]["RoomDetails"];
interface LiveKitRoomProps {
meeting: Meeting;
room: Room;
}
/**
* Extract LiveKit WebSocket URL, room name, and token from the room_url.
*
* The backend returns room_url like: ws://host:7880?room=<name>&token=<jwt>
* We split these for the LiveKit React SDK.
*/
function parseLiveKitUrl(roomUrl: string): {
serverUrl: string;
roomName: string | null;
token: string | null;
} {
try {
const url = new URL(roomUrl);
const token = url.searchParams.get("token");
const roomName = url.searchParams.get("room");
url.searchParams.delete("token");
url.searchParams.delete("room");
// Strip trailing slash and leftover ? from URL API
const serverUrl = url.toString().replace(/[?/]+$/, "");
return { serverUrl, roomName, token };
} catch {
return { serverUrl: roomUrl, roomName: null, token: null };
}
}
export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) {
const router = useRouter();
const params = useParams();
const auth = useAuth();
const authLastUserId = auth.lastUserId;
const roomName = params?.roomName as string;
const meetingId = assertMeetingId(meeting.id);
const joinMutation = useRoomJoinMeeting();
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
const [connectionError, setConnectionError] = useState(false);
// ── Consent dialog (same hooks as Daily/Whereby) ──────────
const { showConsentButton, showRecordingIndicator } = useConsentDialog({
meetingId,
recordingType: meeting.recording_type,
skipConsent: room.skip_consent,
});
// ── Email transcript dialog ───────────────────────────────
const userEmail =
auth.status === "authenticated" || auth.status === "refreshing"
? auth.user.email
: null;
const { showEmailModal } = useEmailTranscriptDialog({
meetingId,
userEmail,
});
const showEmailFeature = featureEnabled("emailTranscript");
// ── Join meeting via backend API to get token ─────────────
useEffect(() => {
if (authLastUserId === undefined || !meeting?.id || !roomName) return;
let cancelled = false;
async function join() {
try {
const result = await joinMutation.mutateAsync({
params: {
path: { room_name: roomName, meeting_id: meeting.id },
},
});
if (!cancelled) setJoinedMeeting(result);
} catch (err) {
console.error("Failed to join LiveKit meeting:", err);
if (!cancelled) setConnectionError(true);
}
}
join();
return () => {
cancelled = true;
};
}, [meeting?.id, roomName, authLastUserId]);
const handleDisconnected = useCallback(() => {
router.push("/browse");
}, [router]);
// ── Loading / error states ────────────────────────────────
if (connectionError) {
return (
<Center h="100vh" bg="gray.50">
<Text fontSize="lg">Failed to connect to meeting</Text>
</Center>
);
}
if (!joinedMeeting) {
return (
<Center h="100vh" bg="gray.50">
<Spinner color="blue.500" size="xl" />
</Center>
);
}
const {
serverUrl,
roomName: lkRoomName,
token,
} = parseLiveKitUrl(joinedMeeting.room_url);
if (
serverUrl &&
!serverUrl.startsWith("ws://") &&
!serverUrl.startsWith("wss://")
) {
console.warn(
`LiveKit serverUrl has unexpected scheme: ${serverUrl}. Expected ws:// or wss://`,
);
}
if (!token || !lkRoomName) {
return (
<Center h="100vh" bg="gray.50">
<Text fontSize="lg">
{!token
? "No access token received from server"
: "No room name received from server"}
</Text>
</Center>
);
}
// ── Render ────────────────────────────────────────────────
// The token already encodes the room name (in VideoGrants.room),
// so LiveKit SDK joins the correct room from the token alone.
return (
<Box w="100vw" h="100vh" bg="black" position="relative">
<LKRoom
serverUrl={serverUrl}
token={token}
connect={true}
audio={true}
video={true}
onDisconnected={handleDisconnected}
data-lk-theme="default"
style={{ height: "100%" }}
>
<VideoConference />
<RoomAudioRenderer />
</LKRoom>
{/* ── Floating overlay buttons (consent, email, extensible) ── */}
{showConsentButton && (
<ConsentDialogButton
meetingId={meetingId}
recordingType={meeting.recording_type}
skipConsent={room.skip_consent}
/>
)}
{showRecordingIndicator && <RecordingIndicator />}
{showEmailFeature && (
<IconButton
aria-label="Email transcript"
position="absolute"
top="56px"
right="8px"
zIndex={1000}
colorPalette="blue"
size="sm"
onClick={showEmailModal}
variant="solid"
borderRadius="full"
>
<LuMail />
</IconButton>
)}
</Box>
);
}

View File

@@ -14,6 +14,7 @@ import MeetingSelection from "../MeetingSelection";
import useRoomDefaultMeeting from "../useRoomDefaultMeeting"; import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
import WherebyRoom from "./WherebyRoom"; import WherebyRoom from "./WherebyRoom";
import DailyRoom from "./DailyRoom"; import DailyRoom from "./DailyRoom";
import LiveKitRoom from "./LiveKitRoom";
import { useAuth } from "../../lib/AuthProvider"; import { useAuth } from "../../lib/AuthProvider";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import { parseNonEmptyString } from "../../lib/utils"; import { parseNonEmptyString } from "../../lib/utils";
@@ -199,8 +200,9 @@ export default function RoomContainer(details: RoomDetails) {
return <DailyRoom meeting={meeting} room={room} />; return <DailyRoom meeting={meeting} room={room} />;
case "whereby": case "whereby":
return <WherebyRoom meeting={meeting} room={room} />; return <WherebyRoom meeting={meeting} room={room} />;
default: { case "livekit":
const _exhaustive: never = platform; return <LiveKitRoom meeting={meeting} room={room} />;
default:
return ( return (
<Box <Box
display="flex" display="flex"
@@ -213,6 +215,5 @@ export default function RoomContainer(details: RoomDetails) {
<Text fontSize="lg">Unknown platform: {platform}</Text> <Text fontSize="lg">Unknown platform: {platform}</Text>
</Box> </Box>
); );
}
} }
} }

View File

@@ -1,4 +1,5 @@
import "./styles/globals.scss"; import "./styles/globals.scss";
import "@livekit/components-styles";
import { Metadata, Viewport } from "next"; import { Metadata, Viewport } from "next";
import { Poppins } from "next/font/google"; import { Poppins } from "next/font/google";
import { ErrorProvider } from "./(errors)/errorContext"; import { ErrorProvider } from "./(errors)/errorContext";

View File

@@ -911,6 +911,32 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/v1/livekit/webhook": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Livekit Webhook
* @description Handle LiveKit webhook events.
*
* LiveKit webhook events include:
* - participant_joined / participant_left
* - egress_started / egress_updated / egress_ended
* - room_started / room_finished
* - track_published / track_unpublished
*/
post: operations["v1_livekit_webhook"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/auth/login": { "/v1/auth/login": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1100,7 +1126,7 @@ export interface components {
* Platform * Platform
* @enum {string} * @enum {string}
*/ */
platform: "whereby" | "daily"; platform: "whereby" | "daily" | "livekit";
/** /**
* Skip Consent * Skip Consent
* @default false * @default false
@@ -1821,7 +1847,7 @@ export interface components {
* Platform * Platform
* @enum {string} * @enum {string}
*/ */
platform: "whereby" | "daily"; platform: "whereby" | "daily" | "livekit";
/** Daily Composed Video S3 Key */ /** Daily Composed Video S3 Key */
daily_composed_video_s3_key?: string | null; daily_composed_video_s3_key?: string | null;
/** Daily Composed Video Duration */ /** Daily Composed Video Duration */
@@ -1921,7 +1947,7 @@ export interface components {
* Platform * Platform
* @enum {string} * @enum {string}
*/ */
platform: "whereby" | "daily"; platform: "whereby" | "daily" | "livekit";
/** /**
* Skip Consent * Skip Consent
* @default false * @default false
@@ -1979,7 +2005,7 @@ export interface components {
* Platform * Platform
* @enum {string} * @enum {string}
*/ */
platform: "whereby" | "daily"; platform: "whereby" | "daily" | "livekit";
/** /**
* Skip Consent * Skip Consent
* @default false * @default false
@@ -2358,7 +2384,7 @@ export interface components {
/** Ics Enabled */ /** Ics Enabled */
ics_enabled?: boolean | null; ics_enabled?: boolean | null;
/** Platform */ /** Platform */
platform?: ("whereby" | "daily") | null; platform?: ("whereby" | "daily" | "livekit") | null;
/** Skip Consent */ /** Skip Consent */
skip_consent?: boolean | null; skip_consent?: boolean | null;
/** Email Transcript To */ /** Email Transcript To */
@@ -4504,6 +4530,26 @@ export interface operations {
}; };
}; };
}; };
v1_livekit_webhook: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
};
};
v1_login: { v1_login: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -20,6 +20,8 @@
"@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0", "@fortawesome/react-fontawesome": "^3.2.0",
"@livekit/components-react": "2.9.20",
"@livekit/components-styles": "1.2.0",
"@sentry/nextjs": "^10.40.0", "@sentry/nextjs": "^10.40.0",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"@whereby.com/browser-sdk": "^3.18.21", "@whereby.com/browser-sdk": "^3.18.21",
@@ -30,6 +32,7 @@
"fontawesome": "^5.6.3", "fontawesome": "^5.6.3",
"ioredis": "^5.10.0", "ioredis": "^5.10.0",
"jest-worker": "^30.2.0", "jest-worker": "^30.2.0",
"livekit-client": "2.18.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",
"next": "16.1.7", "next": "16.1.7",
"next-auth": "^4.24.13", "next-auth": "^4.24.13",

150
www/pnpm-lock.yaml generated
View File

@@ -34,6 +34,12 @@ importers:
'@fortawesome/react-fontawesome': '@fortawesome/react-fontawesome':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.2.0(@fortawesome/fontawesome-svg-core@7.2.0)(react@19.2.4) version: 3.2.0(@fortawesome/fontawesome-svg-core@7.2.0)(react@19.2.4)
'@livekit/components-react':
specifier: 2.9.20
version: 2.9.20(livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tslib@2.8.1)
'@livekit/components-styles':
specifier: 1.2.0
version: 1.2.0
'@sentry/nextjs': '@sentry/nextjs':
specifier: ^10.40.0 specifier: ^10.40.0
version: 10.40.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)(webpack@5.105.3) version: 10.40.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)(webpack@5.105.3)
@@ -64,6 +70,9 @@ importers:
jest-worker: jest-worker:
specifier: ^30.2.0 specifier: ^30.2.0
version: 30.2.0 version: 30.2.0
livekit-client:
specifier: 2.18.0
version: 2.18.0(@types/dom-mediacapture-record@1.0.22)
lucide-react: lucide-react:
specifier: ^0.575.0 specifier: ^0.575.0
version: 0.575.0(react@19.2.4) version: 0.575.0(react@19.2.4)
@@ -343,6 +352,9 @@ packages:
'@bcoe/v8-coverage@0.2.3': '@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
'@bufbuild/protobuf@1.10.1':
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
'@chakra-ui/react@3.33.0': '@chakra-ui/react@3.33.0':
resolution: {integrity: sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==} resolution: {integrity: sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==}
peerDependencies: peerDependencies:
@@ -445,6 +457,9 @@ packages:
'@floating-ui/core@1.7.4': '@floating-ui/core@1.7.4':
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/dom@1.7.5': '@floating-ui/dom@1.7.5':
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
@@ -767,6 +782,36 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@livekit/components-core@0.12.13':
resolution: {integrity: sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==}
engines: {node: '>=18'}
peerDependencies:
livekit-client: ^2.17.2
tslib: ^2.6.2
'@livekit/components-react@2.9.20':
resolution: {integrity: sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==}
engines: {node: '>=18'}
peerDependencies:
'@livekit/krisp-noise-filter': ^0.2.12 || ^0.3.0
livekit-client: ^2.17.2
react: '>=18'
react-dom: '>=18'
tslib: ^2.6.2
peerDependenciesMeta:
'@livekit/krisp-noise-filter':
optional: true
'@livekit/components-styles@1.2.0':
resolution: {integrity: sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==}
engines: {node: '>=18'}
'@livekit/mutex@1.1.1':
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
'@livekit/protocol@1.44.0':
resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==}
'@lukeed/csprng@1.1.0': '@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1886,6 +1931,9 @@ packages:
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/dom-mediacapture-record@1.0.22':
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
@@ -3839,6 +3887,9 @@ packages:
jose@4.15.9: jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
js-levenshtein@1.1.6: js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3984,6 +4035,11 @@ packages:
lines-and-columns@1.2.4: lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
livekit-client@2.18.0:
resolution: {integrity: sha512-wjH4y0rw5fnkPmmaxutPhD4XcAq6goQszS8lw9PEpGXVwiRE6sI/ZH+mOT/s8AHJnEC3tjmfiMZ4MQt8BlaWew==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
loader-runner@4.3.1: loader-runner@4.3.1:
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
engines: {node: '>=6.11.5'} engines: {node: '>=6.11.5'}
@@ -3996,6 +4052,9 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} engines: {node: '>=10'}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.defaults@4.2.0: lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@@ -4005,6 +4064,14 @@ packages:
lodash.memoize@4.1.2: lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
loglevel@1.9.1:
resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==}
engines: {node: '>= 0.6.0'}
loglevel@1.9.2:
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
engines: {node: '>= 0.6.0'}
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -4752,6 +4819,9 @@ packages:
resolution: {integrity: sha512-K6p9y4ZyL9wPzA+PMDloNQPfoDGTiFYDvdlXznyGKgD10BJpcAosvATKrExRKOrNLgD8E7Um7WGW0lxsnOuNLg==} resolution: {integrity: sha512-K6p9y4ZyL9wPzA+PMDloNQPfoDGTiFYDvdlXznyGKgD10BJpcAosvATKrExRKOrNLgD8E7Um7WGW0lxsnOuNLg==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-array-concat@1.1.3: safe-array-concat@1.1.3:
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
@@ -5133,6 +5203,9 @@ packages:
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
typed-emitter@2.1.0:
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
typescript-eslint@8.56.1: typescript-eslint@8.56.1:
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -5228,6 +5301,12 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
usehooks-ts@3.1.1:
resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==}
engines: {node: '>=16.15.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -5662,6 +5741,8 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {} '@bcoe/v8-coverage@0.2.3': {}
'@bufbuild/protobuf@1.10.1': {}
'@chakra-ui/react@3.33.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@chakra-ui/react@3.33.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@ark-ui/react': 5.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@ark-ui/react': 5.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -5811,6 +5892,11 @@ snapshots:
dependencies: dependencies:
'@floating-ui/utils': 0.2.10 '@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.4
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.5': '@floating-ui/dom@1.7.5':
dependencies: dependencies:
'@floating-ui/core': 1.7.4 '@floating-ui/core': 1.7.4
@@ -6179,6 +6265,34 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@livekit/components-core@0.12.13(livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
dependencies:
'@floating-ui/dom': 1.7.4
livekit-client: 2.18.0(@types/dom-mediacapture-record@1.0.22)
loglevel: 1.9.1
rxjs: 7.8.2
tslib: 2.8.1
'@livekit/components-react@2.9.20(livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tslib@2.8.1)':
dependencies:
'@livekit/components-core': 0.12.13(livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
clsx: 2.1.1
events: 3.3.0
jose: 6.2.2
livekit-client: 2.18.0(@types/dom-mediacapture-record@1.0.22)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tslib: 2.8.1
usehooks-ts: 3.1.1(react@19.2.4)
'@livekit/components-styles@1.2.0': {}
'@livekit/mutex@1.1.1': {}
'@livekit/protocol@1.44.0':
dependencies:
'@bufbuild/protobuf': 1.10.1
'@lukeed/csprng@1.1.0': {} '@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1': '@lukeed/uuid@2.0.1':
@@ -7259,6 +7373,8 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/dom-mediacapture-record@1.0.22': {}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
dependencies: dependencies:
'@types/eslint': 9.6.1 '@types/eslint': 9.6.1
@@ -9986,6 +10102,8 @@ snapshots:
jose@4.15.9: {} jose@4.15.9: {}
jose@6.2.2: {}
js-levenshtein@1.1.6: {} js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -10101,6 +10219,19 @@ snapshots:
lines-and-columns@1.2.4: {} lines-and-columns@1.2.4: {}
livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.44.0
'@types/dom-mediacapture-record': 1.0.22
events: 3.3.0
jose: 6.2.2
loglevel: 1.9.2
sdp-transform: 2.15.0
tslib: 2.8.1
typed-emitter: 2.1.0
webrtc-adapter: 9.0.4
loader-runner@4.3.1: {} loader-runner@4.3.1: {}
locate-path@5.0.0: locate-path@5.0.0:
@@ -10111,12 +10242,18 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {} lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {} lodash.isarguments@3.1.0: {}
lodash.memoize@4.1.2: {} lodash.memoize@4.1.2: {}
loglevel@1.9.1: {}
loglevel@1.9.2: {}
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loose-envify@1.4.0: loose-envify@1.4.0:
@@ -11009,6 +11146,10 @@ snapshots:
runes@0.4.3: {} runes@0.4.3: {}
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
safe-array-concat@1.1.3: safe-array-concat@1.1.3:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@@ -11462,6 +11603,10 @@ snapshots:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10 reflect.getprototypeof: 1.0.10
typed-emitter@2.1.0:
optionalDependencies:
rxjs: 7.8.2
typescript-eslint@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3): typescript-eslint@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3):
dependencies: dependencies:
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
@@ -11585,6 +11730,11 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
usehooks-ts@3.1.1(react@19.2.4):
dependencies:
lodash.debounce: 4.0.8
react: 19.2.4
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
uuid-validate@0.0.3: {} uuid-validate@0.0.3: {}