diff --git a/.gitignore b/.gitignore index 2eabf49c..b244fd4f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ server/.env server/.env.production .env Caddyfile +livekit.yaml +egress.yaml .env.hatchet server/exportdanswer .vercel diff --git a/docker-compose.selfhosted.yml b/docker-compose.selfhosted.yml index 9c554fe7..49d49061 100644 --- a/docker-compose.selfhosted.yml +++ b/docker-compose.selfhosted.yml @@ -406,6 +406,40 @@ services: volumes: - 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: postgres_data: redis_data: diff --git a/docsv2/livekit-setup.md b/docsv2/livekit-setup.md new file mode 100644 index 00000000..a88374de --- /dev/null +++ b/docsv2/livekit-setup.md @@ -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://:7880`. With `--domain`, set to `wss://: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:///lk-ws` (or `wss:///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 `` 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 +``` diff --git a/docsv2/selfhosted-architecture.md b/docsv2/selfhosted-architecture.md index 3a47a74d..2f5a4c22 100644 --- a/docsv2/selfhosted-architecture.md +++ b/docsv2/selfhosted-architecture.md @@ -170,6 +170,8 @@ These start regardless of which flags you pass: | `ollama-cpu` | `ollama-cpu` | Local Ollama LLM on CPU | | `garage` | `garage` | Local S3-compatible object storage | | `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 @@ -206,11 +208,17 @@ Both the `gpu` and `cpu` services define a Docker network alias of `transcriptio │ :8000 │ └─────────┘ └─────────┘ └───────────┘ │ - ┌─────┴─────┐ ┌─────────┐ - │ ollama │ │ garage │ - │(optional) │ │(optional│ - │ :11435 │ │ S3) │ - └───────────┘ └─────────┘ + ┌─────┴─────┐ ┌─────────┐ ┌──────────────┐ + │ ollama │ │ garage │ │livekit-server│ + │(optional) │ │(optional│ │ (optional) │ + │ :11435 │ │ S3) │ │ :7880 │ + └───────────┘ └─────────┘ └──────┬───────┘ + │ + ┌──────┴───────┐ + │livekit-egress│ + │ (Track Egress│ + │ to S3) │ + └──────────────┘ ``` ### How Services Interact @@ -320,7 +328,9 @@ You can point your own reverse proxy (nginx, Traefik, etc.) at these ports. ### 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 | | 8000 | GPU/CPU | `127.0.0.1:8000` | ML model 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. @@ -443,6 +456,8 @@ Inside the Docker network, services reach each other by their compose service na | `transcription` | GPU or CPU container (network alias) | | `ollama` / `ollama-cpu` | Ollama container | | `garage` | Garage S3 container | +| `livekit-server` | LiveKit SFU server | +| `livekit-egress` | LiveKit Track Egress service | --- diff --git a/docsv2/selfhosted-production.md b/docsv2/selfhosted-production.md index f043ab2f..69e1c95a 100644 --- a/docsv2/selfhosted-production.md +++ b/docsv2/selfhosted-production.md @@ -144,6 +144,7 @@ Browse all available models at https://ollama.com/library. | 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. | | `--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. | @@ -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). +## 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. **Without a domain:** `--caddy` alone uses a self-signed certificate. Browsers will show a security warning that must be accepted. diff --git a/egress.yaml.example b/egress.yaml.example new file mode 100644 index 00000000..e7fb04b7 --- /dev/null +++ b/egress.yaml.example @@ -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. diff --git a/livekit.yaml.example b/livekit.yaml.example new file mode 100644 index 00000000..cd3cfb8a --- /dev/null +++ b/livekit.yaml.example @@ -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 diff --git a/scripts/setup-selfhosted.sh b/scripts/setup-selfhosted.sh index c716ebf0..4fa78a17 100755 --- a/scripts/setup-selfhosted.sh +++ b/scripts/setup-selfhosted.sh @@ -26,6 +26,8 @@ # (If omitted, configure an external OpenAI-compatible LLM in server/.env) # # 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 # --caddy Enable Caddy reverse proxy with auto-SSL # --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 # # Examples: -# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy -# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy --domain reflector.example.com -# ./scripts/setup-selfhosted.sh --cpu --ollama-cpu --garage --caddy -# ./scripts/setup-selfhosted.sh --hosted --garage --caddy +# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy +# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy --domain reflector.example.com +# ./scripts/setup-selfhosted.sh --cpu --ollama-cpu --livekit --garage --caddy +# ./scripts/setup-selfhosted.sh --hosted --livekit --garage --caddy # ./scripts/setup-selfhosted.sh --cpu --padding modal --garage --caddy # ./scripts/setup-selfhosted.sh --gpu --translation passthrough --garage --caddy # ./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. # Re-running with no arguments replays the saved configuration automatically. # -# The script auto-detects Daily.co (DAILY_API_KEY) and Whereby (WHEREBY_API_KEY) -# from server/.env. If Daily.co is configured, Hatchet workflow services are -# started automatically for multitrack recording processing. +# The script auto-detects Daily.co (DAILY_API_KEY), Whereby (WHEREBY_API_KEY), +# and LiveKit (LIVEKIT_API_KEY) from server/.env. +# - 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. # @@ -207,6 +211,7 @@ fi MODEL_MODE="" # gpu or cpu (required, mutually exclusive) OLLAMA_MODE="" # ollama-gpu or ollama-cpu (optional) USE_GARAGE=false +USE_LIVEKIT=false USE_CADDY=false CUSTOM_DOMAIN="" # optional domain for Let's Encrypt HTTPS BUILD_IMAGES=false # build backend/frontend from source @@ -261,6 +266,7 @@ for i in "${!ARGS[@]}"; do OLLAMA_MODEL="${ARGS[$next_i]}" SKIP_NEXT=true ;; --garage) USE_GARAGE=true ;; + --livekit) USE_LIVEKIT=true ;; --caddy) USE_CADDY=true ;; --build) BUILD_IMAGES=true ;; --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)" 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 # ========================================================= @@ -1014,14 +1127,17 @@ step_www_env() { fi # 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 _daily_key=$(env_get "$SERVER_ENV" "DAILY_API_KEY") fi if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY"; then _whereby_key=$(env_get "$SERVER_ENV" "WHEREBY_API_KEY") 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" ok "Rooms feature enabled (video platform configured)" fi @@ -1188,6 +1304,20 @@ step_caddyfile() { rm -rf "$caddyfile" 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 # Custom domain with user-provided TLS certificate (from --custom-ca directory) cat > "$caddyfile" << CADDYEOF @@ -1199,7 +1329,7 @@ $CUSTOM_DOMAIN { } handle /health { reverse_proxy server:1250 - } + }${lk_proxy_block} handle { reverse_proxy web:3000 } @@ -1216,7 +1346,7 @@ $CUSTOM_DOMAIN { } handle /health { reverse_proxy server:1250 - } + }${lk_proxy_block} handle { reverse_proxy web:3000 } @@ -1235,7 +1365,7 @@ CADDYEOF } handle /health { reverse_proxy server:1250 - } + }${lk_proxy_block} handle { reverse_proxy web:3000 } @@ -1621,14 +1751,21 @@ main() { # Auto-detect video platforms from server/.env (after step_server_env so file exists) DAILY_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 DAILY_DETECTED=true fi if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "WHEREBY_API_KEY")" ]]; then WHEREBY_DETECTED=true 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 - [[ "$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 if [[ "$DAILY_DETECTED" == "true" ]]; then @@ -1636,6 +1773,13 @@ main() { ok "Daily.co detected — enabling Hatchet workflow services" 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) local hatchet_server_url hatchet_cookie_domain if [[ -n "$CUSTOM_DOMAIN" ]]; then @@ -1702,6 +1846,7 @@ EOF [[ "$USES_OLLAMA" != "true" ]] && echo " LLM: External (configure in server/.env)" [[ "$DAILY_DETECTED" == "true" ]] && echo " Video: Daily.co (live rooms + multitrack processing via Hatchet)" [[ "$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)" if [[ "$USE_CUSTOM_CA" == "true" ]]; then echo " CA: Custom (certs/ca.crt)" diff --git a/server/pyproject.toml b/server/pyproject.toml index f01e1f8f..619ff3f1 100644 --- a/server/pyproject.toml +++ b/server/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "pydantic>=2.12.5", "aiosmtplib>=3.0.0", "email-validator>=2.0.0", + "livekit-api>=1.1.0", ] [dependency-groups] diff --git a/server/reflector/app.py b/server/reflector/app.py index 1dd88c48..f5eed741 100644 --- a/server/reflector/app.py +++ b/server/reflector/app.py @@ -15,6 +15,7 @@ from reflector.metrics import metrics_init from reflector.settings import settings from reflector.views.config import router as config_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.rooms import router as rooms_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(whereby_router, prefix="/v1") app.include_router(daily_router, prefix="/v1/daily") +app.include_router(livekit_router, prefix="/v1/livekit") if auth_router: app.include_router(auth_router, prefix="/v1") add_pagination(app) diff --git a/server/reflector/livekit_api/__init__.py b/server/reflector/livekit_api/__init__.py new file mode 100644 index 00000000..c9870145 --- /dev/null +++ b/server/reflector/livekit_api/__init__.py @@ -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", +] diff --git a/server/reflector/livekit_api/client.py b/server/reflector/livekit_api/client.py new file mode 100644 index 00000000..48f533d0 --- /dev/null +++ b/server/reflector/livekit_api/client.py @@ -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() diff --git a/server/reflector/livekit_api/webhooks.py b/server/reflector/livekit_api/webhooks.py new file mode 100644 index 00000000..89fd61f8 --- /dev/null +++ b/server/reflector/livekit_api/webhooks.py @@ -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 diff --git a/server/reflector/schemas/platform.py b/server/reflector/schemas/platform.py index 7b945841..80aefa9f 100644 --- a/server/reflector/schemas/platform.py +++ b/server/reflector/schemas/platform.py @@ -1,5 +1,6 @@ from typing import Literal -Platform = Literal["whereby", "daily"] +Platform = Literal["whereby", "daily", "livekit"] WHEREBY_PLATFORM: Platform = "whereby" DAILY_PLATFORM: Platform = "daily" +LIVEKIT_PLATFORM: Platform = "livekit" diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 5cbbe8c1..fc94ddaa 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -195,6 +195,23 @@ class Settings(BaseSettings): DAILY_WEBHOOK_UUID: str | None = ( 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 DEFAULT_VIDEO_PLATFORM: Platform = DAILY_PLATFORM diff --git a/server/reflector/storage/__init__.py b/server/reflector/storage/__init__.py index 2f01d9f3..51f1290a 100644 --- a/server/reflector/storage/__init__.py +++ b/server/reflector/storage/__init__.py @@ -57,6 +57,22 @@ def get_source_storage(platform: str) -> Storage: 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() diff --git a/server/reflector/video_platforms/factory.py b/server/reflector/video_platforms/factory.py index 7c60c379..b80a01be 100644 --- a/server/reflector/video_platforms/factory.py +++ b/server/reflector/video_platforms/factory.py @@ -1,7 +1,7 @@ from reflector.settings import settings 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 .registry import get_platform_client @@ -44,6 +44,27 @@ def get_platform_config(platform: Platform) -> VideoPlatformConfig: s3_region=daily_storage.region, 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: raise ValueError(f"Unknown platform: {platform}") diff --git a/server/reflector/video_platforms/livekit.py b/server/reflector/video_platforms/livekit.py new file mode 100644 index 00000000..ab579bfd --- /dev/null +++ b/server/reflector/video_platforms/livekit.py @@ -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=&token= + 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() diff --git a/server/reflector/video_platforms/registry.py b/server/reflector/video_platforms/registry.py index b4c10697..c0490353 100644 --- a/server/reflector/video_platforms/registry.py +++ b/server/reflector/video_platforms/registry.py @@ -1,6 +1,11 @@ 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 _PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {} @@ -26,10 +31,12 @@ def get_available_platforms() -> list[Platform]: def _register_builtin_platforms(): from .daily import DailyClient # noqa: PLC0415 + from .livekit import LiveKitClient # noqa: PLC0415 from .whereby import WherebyClient # noqa: PLC0415 register_platform(WHEREBY_PLATFORM, WherebyClient) register_platform(DAILY_PLATFORM, DailyClient) + register_platform(LIVEKIT_PLATFORM, LiveKitClient) _register_builtin_platforms() diff --git a/server/reflector/views/livekit.py b/server/reflector/views/livekit.py new file mode 100644 index 00000000..a02fae43 --- /dev/null +++ b/server/reflector/views/livekit.py @@ -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 diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 7a51ef41..c9ac2f2f 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -598,4 +598,22 @@ async def rooms_join_meeting( meeting = meeting.model_copy() 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 diff --git a/server/reflector/worker/app.py b/server/reflector/worker/app.py index 7df74263..9bd05491 100644 --- a/server/reflector/worker/app.py +++ b/server/reflector/worker/app.py @@ -83,7 +83,11 @@ def build_beat_schedule( else: 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: beat_schedule["process_meetings"] = { "task": "reflector.worker.process.process_meetings", diff --git a/server/uv.lock b/server/uv.lock index c619843f..7643ef61 100644 --- a/server/uv.lock +++ b/server/uv.lock @@ -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" }, ] +[[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]] name = "llama-cloud" version = "0.1.35" @@ -3364,6 +3393,7 @@ dependencies = [ { name = "httpx" }, { name = "icalendar" }, { name = "jsonschema" }, + { name = "livekit-api" }, { name = "llama-index" }, { name = "llama-index-llms-openai-like" }, { name = "openai" }, @@ -3445,6 +3475,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.24.1" }, { name = "icalendar", specifier = ">=6.0.0" }, { name = "jsonschema", specifier = ">=4.23.0" }, + { name = "livekit-api", specifier = ">=1.1.0" }, { name = "llama-index", specifier = ">=0.12.52" }, { name = "llama-index-llms-openai-like", specifier = ">=0.4.0" }, { 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" }, ] +[[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]] name = "typing-extensions" version = "4.14.1" diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 7c81f3fe..f03315e8 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -74,6 +74,7 @@ const recordingTypeOptions: SelectOption[] = [ const platformOptions: SelectOption[] = [ { label: "Whereby", value: "whereby" }, { label: "Daily", value: "daily" }, + { label: "LiveKit", value: "livekit" }, ]; const roomInitialState = { @@ -309,10 +310,7 @@ export default function RoomsList() { return; } - const platform: "whereby" | "daily" = - room.platform === "whereby" || room.platform === "daily" - ? room.platform - : "daily"; + const platform = room.platform as "whereby" | "daily" | "livekit"; const roomData = { name: room.name, @@ -544,7 +542,10 @@ export default function RoomsList() { { - const newPlatform = e.value[0] as "whereby" | "daily"; + const newPlatform = e.value[0] as + | "whereby" + | "daily" + | "livekit"; const updates: Partial = { platform: newPlatform, }; diff --git a/www/app/[roomName]/components/LiveKitRoom.tsx b/www/app/[roomName]/components/LiveKitRoom.tsx new file mode 100644 index 00000000..7cdfed0c --- /dev/null +++ b/www/app/[roomName]/components/LiveKitRoom.tsx @@ -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=&token= + * 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(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 ( +
+ Failed to connect to meeting +
+ ); + } + + if (!joinedMeeting) { + return ( +
+ +
+ ); + } + + 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 ( +
+ + {!token + ? "No access token received from server" + : "No room name received from server"} + +
+ ); + } + + // ── Render ──────────────────────────────────────────────── + // The token already encodes the room name (in VideoGrants.room), + // so LiveKit SDK joins the correct room from the token alone. + return ( + + + + + + + {/* ── Floating overlay buttons (consent, email, extensible) ── */} + {showConsentButton && ( + + )} + + {showRecordingIndicator && } + + {showEmailFeature && ( + + + + )} + + ); +} diff --git a/www/app/[roomName]/components/RoomContainer.tsx b/www/app/[roomName]/components/RoomContainer.tsx index 88a5210f..eddd026f 100644 --- a/www/app/[roomName]/components/RoomContainer.tsx +++ b/www/app/[roomName]/components/RoomContainer.tsx @@ -14,6 +14,7 @@ import MeetingSelection from "../MeetingSelection"; import useRoomDefaultMeeting from "../useRoomDefaultMeeting"; import WherebyRoom from "./WherebyRoom"; import DailyRoom from "./DailyRoom"; +import LiveKitRoom from "./LiveKitRoom"; import { useAuth } from "../../lib/AuthProvider"; import { useError } from "../../(errors)/errorContext"; import { parseNonEmptyString } from "../../lib/utils"; @@ -199,8 +200,9 @@ export default function RoomContainer(details: RoomDetails) { return ; case "whereby": return ; - default: { - const _exhaustive: never = platform; + case "livekit": + return ; + default: return ( Unknown platform: {platform} ); - } } } diff --git a/www/app/layout.tsx b/www/app/layout.tsx index 40716996..0079941d 100644 --- a/www/app/layout.tsx +++ b/www/app/layout.tsx @@ -1,4 +1,5 @@ import "./styles/globals.scss"; +import "@livekit/components-styles"; import { Metadata, Viewport } from "next"; import { Poppins } from "next/font/google"; import { ErrorProvider } from "./(errors)/errorContext"; diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index 4b349f82..f759b513 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -911,6 +911,32 @@ export interface paths { patch?: 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": { parameters: { query?: never; @@ -1100,7 +1126,7 @@ export interface components { * Platform * @enum {string} */ - platform: "whereby" | "daily"; + platform: "whereby" | "daily" | "livekit"; /** * Skip Consent * @default false @@ -1821,7 +1847,7 @@ export interface components { * Platform * @enum {string} */ - platform: "whereby" | "daily"; + platform: "whereby" | "daily" | "livekit"; /** Daily Composed Video S3 Key */ daily_composed_video_s3_key?: string | null; /** Daily Composed Video Duration */ @@ -1921,7 +1947,7 @@ export interface components { * Platform * @enum {string} */ - platform: "whereby" | "daily"; + platform: "whereby" | "daily" | "livekit"; /** * Skip Consent * @default false @@ -1979,7 +2005,7 @@ export interface components { * Platform * @enum {string} */ - platform: "whereby" | "daily"; + platform: "whereby" | "daily" | "livekit"; /** * Skip Consent * @default false @@ -2358,7 +2384,7 @@ export interface components { /** Ics Enabled */ ics_enabled?: boolean | null; /** Platform */ - platform?: ("whereby" | "daily") | null; + platform?: ("whereby" | "daily" | "livekit") | null; /** Skip Consent */ skip_consent?: boolean | null; /** 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: { parameters: { query?: never; diff --git a/www/package.json b/www/package.json index ae3f0feb..768ddae4 100644 --- a/www/package.json +++ b/www/package.json @@ -20,6 +20,8 @@ "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.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", "@tanstack/react-query": "^5.90.21", "@whereby.com/browser-sdk": "^3.18.21", @@ -30,6 +32,7 @@ "fontawesome": "^5.6.3", "ioredis": "^5.10.0", "jest-worker": "^30.2.0", + "livekit-client": "2.18.0", "lucide-react": "^0.575.0", "next": "16.1.7", "next-auth": "^4.24.13", diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml index 958e01cc..59ca1908 100644 --- a/www/pnpm-lock.yaml +++ b/www/pnpm-lock.yaml @@ -34,6 +34,12 @@ importers: '@fortawesome/react-fontawesome': specifier: ^3.2.0 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': 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) @@ -64,6 +70,9 @@ importers: jest-worker: specifier: ^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: specifier: ^0.575.0 version: 0.575.0(react@19.2.4) @@ -343,6 +352,9 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bufbuild/protobuf@1.10.1': + resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} + '@chakra-ui/react@3.33.0': resolution: {integrity: sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==} peerDependencies: @@ -445,6 +457,9 @@ packages: '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} + '@floating-ui/dom@1.7.5': resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} @@ -767,6 +782,36 @@ packages: '@jridgewell/trace-mapping@0.3.31': 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': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -1886,6 +1931,9 @@ packages: '@types/debug@4.1.12': 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': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -3839,6 +3887,9 @@ packages: jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-levenshtein@1.1.6: resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} engines: {node: '>=0.10.0'} @@ -3984,6 +4035,11 @@ packages: lines-and-columns@1.2.4: 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: resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} engines: {node: '>=6.11.5'} @@ -3996,6 +4052,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -4005,6 +4064,14 @@ packages: lodash.memoize@4.1.2: 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: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -4752,6 +4819,9 @@ packages: resolution: {integrity: sha512-K6p9y4ZyL9wPzA+PMDloNQPfoDGTiFYDvdlXznyGKgD10BJpcAosvATKrExRKOrNLgD8E7Um7WGW0lxsnOuNLg==} engines: {node: '>=4.0.0'} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -5133,6 +5203,9 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} + typed-emitter@2.1.0: + resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} + typescript-eslint@8.56.1: resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5228,6 +5301,12 @@ packages: '@types/react': 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: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5662,6 +5741,8 @@ snapshots: '@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)': dependencies: '@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: '@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': dependencies: '@floating-ui/core': 1.7.4 @@ -6179,6 +6265,34 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@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/uuid@2.0.1': @@ -7259,6 +7373,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/dom-mediacapture-record@1.0.22': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -9986,6 +10102,8 @@ snapshots: jose@4.15.9: {} + jose@6.2.2: {} + js-levenshtein@1.1.6: {} js-tokens@4.0.0: {} @@ -10101,6 +10219,19 @@ snapshots: 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: {} locate-path@5.0.0: @@ -10111,12 +10242,18 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} lodash.memoize@4.1.2: {} + loglevel@1.9.1: {} + + loglevel@1.9.2: {} + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -11009,6 +11146,10 @@ snapshots: runes@0.4.3: {} + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -11462,6 +11603,10 @@ snapshots: possible-typed-array-names: 1.1.0 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): 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) @@ -11585,6 +11730,11 @@ snapshots: optionalDependencies: '@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: {} uuid-validate@0.0.3: {}