From bc8338fa4f136534f5f27784f5dd10d47cecf412 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Diego=20Garc=C3=ADa?= Date: Tue, 7 Apr 2026 11:55:16 -0500 Subject: [PATCH] feat: Livekit - Selfhost video room solution (#946) * feat: Livekit bare no recording nor pipeline * feat: full livekit pipeline * fix: caddy hatchet with livekit * fix: caddy livekit * fix: hatchet tls * fix: agg to webm for no padding * fix: reflector user id on participants and duration fix * fix: better docs and internal review fixes * fix: remove video files livekit --- .gitignore | 2 + docker-compose.selfhosted.yml | 42 +- docsv2/firewall-ports.md | 97 +++++ docsv2/livekit-setup.md | 297 +++++++++++++ docsv2/migrate-daily-to-livekit.md | 73 ++++ docsv2/selfhosted-architecture.md | 29 +- docsv2/selfhosted-production.md | 15 + egress.yaml.example | 26 ++ livekit.yaml.example | 34 ++ scripts/setup-selfhosted.sh | 300 ++++++++++--- server/pyproject.toml | 1 + server/reflector/app.py | 2 + server/reflector/db/meetings.py | 11 + server/reflector/db/transcripts.py | 8 + .../workflows/daily_multitrack_pipeline.py | 289 ++++++++++--- .../hatchet/workflows/track_processing.py | 27 +- server/reflector/livekit_api/__init__.py | 12 + server/reflector/livekit_api/client.py | 195 +++++++++ server/reflector/livekit_api/webhooks.py | 52 +++ server/reflector/schemas/platform.py | 3 +- .../reflector/services/transcript_process.py | 5 + server/reflector/settings.py | 17 + server/reflector/storage/__init__.py | 16 + server/reflector/utils/livekit.py | 112 +++++ server/reflector/video_platforms/factory.py | 23 +- server/reflector/video_platforms/livekit.py | 192 +++++++++ server/reflector/video_platforms/registry.py | 9 +- server/reflector/views/livekit.py | 246 +++++++++++ server/reflector/views/rooms.py | 48 +++ server/reflector/worker/app.py | 20 +- server/reflector/worker/process.py | 324 ++++++++++++++ server/tests/test_livekit_backend.py | 408 ++++++++++++++++++ server/tests/test_livekit_track_processing.py | 393 +++++++++++++++++ server/uv.lock | 40 ++ www/app/(app)/rooms/page.tsx | 11 +- www/app/[roomName]/components/LiveKitRoom.tsx | 277 ++++++++++++ .../[roomName]/components/RoomContainer.tsx | 7 +- www/app/layout.tsx | 1 + www/app/reflector-api.d.ts | 60 ++- www/package.json | 3 + www/pnpm-lock.yaml | 150 +++++++ 41 files changed, 3731 insertions(+), 146 deletions(-) create mode 100644 docsv2/firewall-ports.md create mode 100644 docsv2/livekit-setup.md create mode 100644 docsv2/migrate-daily-to-livekit.md create mode 100644 egress.yaml.example create mode 100644 livekit.yaml.example create mode 100644 server/reflector/livekit_api/__init__.py create mode 100644 server/reflector/livekit_api/client.py create mode 100644 server/reflector/livekit_api/webhooks.py create mode 100644 server/reflector/utils/livekit.py create mode 100644 server/reflector/video_platforms/livekit.py create mode 100644 server/reflector/views/livekit.py create mode 100644 server/tests/test_livekit_backend.py create mode 100644 server/tests/test_livekit_track_processing.py create mode 100644 www/app/[roomName]/components/LiveKitRoom.tsx 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..ef606459 100644 --- a/docker-compose.selfhosted.yml +++ b/docker-compose.selfhosted.yml @@ -35,7 +35,7 @@ services: image: monadicalsas/reflector-backend:latest restart: unless-stopped ports: - - "127.0.0.1:1250:1250" + - "${BIND_HOST:-127.0.0.1}:1250:1250" - "40000-40100:40000-40100/udp" env_file: - ./server/.env @@ -116,7 +116,7 @@ services: image: monadicalsas/reflector-frontend:latest restart: unless-stopped ports: - - "127.0.0.1:3000:3000" + - "${BIND_HOST:-127.0.0.1}:3000:3000" env_file: - ./www/.env environment: @@ -339,7 +339,7 @@ services: postgres: condition: service_healthy ports: - - "127.0.0.1:8888:8888" + - "0.0.0.0:8888:8888" # Hatchet dashboard (plain HTTP — no TLS) - "127.0.0.1:7078:7077" env_file: - ./.env.hatchet @@ -366,7 +366,7 @@ services: context: ./server dockerfile: Dockerfile image: monadicalsas/reflector-backend:latest - profiles: [dailyco] + profiles: [dailyco, livekit] restart: unless-stopped env_file: - ./server/.env @@ -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/firewall-ports.md b/docsv2/firewall-ports.md new file mode 100644 index 00000000..f2d091e8 --- /dev/null +++ b/docsv2/firewall-ports.md @@ -0,0 +1,97 @@ +# Firewall & Port Requirements + +Ports that need to be open on your server firewall, organized by deployment mode. + +## With Caddy (--caddy or --ip or --domain) + +Caddy acts as the reverse proxy. Most services are only accessible through Caddy on port 443. + +| Port | Protocol | Direction | Service | Required? | +|------|----------|-----------|---------|-----------| +| 443 | TCP | Inbound | Caddy HTTPS — web app, API, LiveKit signaling (`/lk-ws`) | Yes | +| 80 | TCP | Inbound | Caddy HTTP — redirects to HTTPS | Yes | +| 44200-44300 | UDP | Inbound | LiveKit WebRTC media (audio/video) | Yes (if LiveKit enabled) | +| 7881 | TCP | Inbound | LiveKit TCP media fallback (when UDP is blocked by client network) | Recommended | +| 8888 | TCP | Inbound | Hatchet dashboard (plain HTTP, no TLS) | Optional (admin only) | + +Ports that do NOT need to be open (proxied through Caddy): +- 1250 (backend API) +- 3000 (frontend) +- 7880 (LiveKit signaling — proxied via `/lk-ws`) +- 3900 (Garage S3) + +## Without Caddy (direct access) + +All services need direct port access. Use this only for local development or trusted networks. + +| Port | Protocol | Direction | Service | Required? | +|------|----------|-----------|---------|-----------| +| 3000 | TCP | Inbound | Frontend (Next.js) | Yes | +| 1250 | TCP | Inbound | Backend API (FastAPI) | Yes | +| 7880 | TCP | Inbound | LiveKit signaling (WebSocket) | Yes (if LiveKit enabled) | +| 7881 | TCP | Inbound | LiveKit TCP media fallback | Recommended | +| 44200-44300 | UDP | Inbound | LiveKit WebRTC media | Yes (if LiveKit enabled) | +| 40000-40100 | UDP | Inbound | Reflector WebRTC (browser recording) | Yes (if using browser WebRTC) | +| 3900 | TCP | Inbound | Garage S3 (for presigned URLs in browser) | Yes (if using Garage) | +| 8888 | TCP | Inbound | Hatchet dashboard | Optional | + +> **Important:** Without Caddy, all traffic is plain HTTP. Browsers block microphone/camera access on non-HTTPS pages (except `localhost`). Use `--ip` (which implies Caddy) for any non-localhost deployment. + +## Internal-Only Ports (never expose) + +These ports are used between Docker containers and should NOT be open on the firewall: + +| Port | Service | Purpose | +|------|---------|---------| +| 5432 | PostgreSQL | Database | +| 6379 | Redis | Cache + message broker | +| 7077 | Hatchet gRPC | Worker communication | + +## Cloud Provider Firewall Examples + +### DigitalOcean (with Caddy + LiveKit) + +```bash +# Create firewall +doctl compute firewall create \ + --name reflector \ + --inbound-rules "protocol:tcp,ports:443,address:0.0.0.0/0 protocol:tcp,ports:80,address:0.0.0.0/0 protocol:udp,ports:44200-44300,address:0.0.0.0/0 protocol:tcp,ports:7881,address:0.0.0.0/0 protocol:tcp,ports:22,address:0.0.0.0/0" \ + --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0 protocol:udp,ports:all,address:0.0.0.0/0" \ + --droplet-ids +``` + +### AWS Security Group (with Caddy + LiveKit) + +| Type | Port Range | Source | Description | +|------|-----------|--------|-------------| +| HTTPS | 443 | 0.0.0.0/0 | Web app + API + LiveKit signaling | +| HTTP | 80 | 0.0.0.0/0 | Redirect to HTTPS | +| Custom UDP | 44200-44300 | 0.0.0.0/0 | LiveKit WebRTC media | +| Custom TCP | 7881 | 0.0.0.0/0 | LiveKit TCP fallback | +| SSH | 22 | Your IP | Admin access | + +### Ubuntu UFW (with Caddy + LiveKit) + +```bash +sudo ufw allow 443/tcp # Caddy HTTPS +sudo ufw allow 80/tcp # HTTP redirect +sudo ufw allow 7881/tcp # LiveKit TCP fallback +sudo ufw allow 44200:44300/udp # LiveKit WebRTC media +sudo ufw allow 22/tcp # SSH +sudo ufw enable +``` + +## Port Ranges Explained + +### Why 44200-44300 for LiveKit? + +LiveKit's WebRTC ICE candidates use UDP. The port range was chosen to avoid collisions: +- **40000-40100** — Reflector's own WebRTC (browser recording) +- **44200-44300** — LiveKit WebRTC +- **49152-65535** — macOS ephemeral ports (reserved by OS) + +The range is configurable in `livekit.yaml` under `rtc.port_range_start` / `rtc.port_range_end`. If changed, update `docker-compose.selfhosted.yml` port mapping to match. + +### Why 101 ports? + +100 UDP ports support ~100 concurrent WebRTC connections (roughly 50 participants with audio + video). For larger deployments, increase the range in both `livekit.yaml` and `docker-compose.selfhosted.yml`. diff --git a/docsv2/livekit-setup.md b/docsv2/livekit-setup.md new file mode 100644 index 00000000..ea8a28ae --- /dev/null +++ b/docsv2/livekit-setup.md @@ -0,0 +1,297 @@ +# 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://`. + +### Security Note: on_demand TLS + +When using `--ip` (Caddy with self-signed certs), the Caddyfile uses `tls internal { on_demand }`. This generates certificates dynamically for any hostname/IP on first TLS request. + +**Risk:** An attacker can trigger certificate generation for arbitrary hostnames by sending TLS requests with spoofed SNI values, causing disk and CPU usage. This is a low-severity resource exhaustion risk, not a data theft risk. + +**Mitigations:** +- For LAN/development use: not a concern (not internet-exposed) +- For cloud VMs: restrict port 443 access via firewall to trusted IPs +- For production: use `--domain` with a real domain name instead of `--ip` — Caddy uses Let's Encrypt (no `on_demand` needed) + +| 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/migrate-daily-to-livekit.md b/docsv2/migrate-daily-to-livekit.md new file mode 100644 index 00000000..5c764462 --- /dev/null +++ b/docsv2/migrate-daily-to-livekit.md @@ -0,0 +1,73 @@ +# Migrating from Daily.co to LiveKit + +This guide covers running LiveKit alongside Daily.co or fully replacing it. + +## Both Platforms Run Simultaneously + +LiveKit and Daily.co coexist — the platform is selected **per room**. You don't need to migrate all rooms at once. + +- Existing Daily rooms continue to work as-is +- New rooms can use LiveKit +- Each room's `platform` field determines which video service is used +- Transcripts, topics, summaries work identically regardless of platform + +## Step 1: Enable LiveKit + +Add `--livekit` to your setup command: + +```bash +# If currently running: +./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy + +# Add --livekit: +./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy +``` + +This starts `livekit-server` + `livekit-egress` containers alongside your existing stack. + +## Step 2: Set Default Platform + +The setup script automatically sets `DEFAULT_VIDEO_PLATFORM=livekit` in `server/.env`. This means **new rooms** default to LiveKit. Existing rooms keep their current platform. + +To keep Daily as the default for new rooms: +```bash +# In server/.env, change: +DEFAULT_VIDEO_PLATFORM=daily +``` + +## Step 3: Switch Individual Rooms + +In the Rooms admin page, edit any room and change the **Platform** dropdown from "Daily" to "LiveKit". The next meeting in that room will use LiveKit. + +Previously recorded Daily transcripts for that room are unaffected. + +## Step 4: (Optional) Remove Daily.co + +Once all rooms use LiveKit and you no longer need Daily.co: + +1. Remove `DAILY_API_KEY` and related Daily settings from `server/.env` +2. Re-run the setup script — it won't activate the `dailyco` profile +3. Hatchet workers are shared between Daily and LiveKit, so they continue running + +Daily-specific services that stop: +- `hatchet-worker-cpu` with `dailyco` profile (but continues if `livekit` profile is active) +- Daily webhook polling tasks (`poll_daily_recordings`, etc.) + +## What Changes for Users + +| Feature | Daily.co | LiveKit | +|---------|---------|---------| +| Video/audio quality | Daily.co SFU | LiveKit SFU (comparable) | +| Pre-join screen | Daily's built-in iframe | LiveKit PreJoin component (name + device selection) | +| Recording | Starts via REST API from frontend | Auto Track Egress (automatic, no user action) | +| Multitrack audio | Per-participant WebM tracks | Per-participant OGG tracks | +| Transcript quality | Same pipeline | Same pipeline | +| Self-hosted | No (SaaS only) | Yes (fully self-hosted) | + +## Database Changes + +None required. The `platform` field on rooms and meetings already supports `"livekit"`. LiveKit recordings use recording IDs prefixed with `lk-` to distinguish them from Daily recordings. + +## Rollback + +To revert a room back to Daily, just change the Platform dropdown back to "Daily" in the Rooms admin page. No data migration needed. 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..f4e2011f 100755 --- a/scripts/setup-selfhosted.sh +++ b/scripts/setup-selfhosted.sh @@ -26,6 +26,12 @@ # (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) +# --ip IP Set the server's IP address for all URLs. Implies --caddy +# (self-signed HTTPS, required for browser mic/camera access). +# Mutually exclusive with --domain. Use for LAN or cloud VM access. +# On Linux, IP is auto-detected; on macOS, use --ip to specify it. # --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 +48,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 +64,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,8 +215,10 @@ 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 +CUSTOM_IP="" # optional --ip override (mutually exclusive with --caddy) BUILD_IMAGES=false # build backend/frontend from source ADMIN_PASSWORD="" # optional admin password for password auth CUSTOM_CA="" # --custom-ca: path to dir or CA cert file @@ -261,7 +271,16 @@ 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 ;; + --ip) + next_i=$((i + 1)) + if [[ $next_i -ge ${#ARGS[@]} ]] || [[ "${ARGS[$next_i]}" == --* ]]; then + err "--ip requires an IP address (e.g. --ip 192.168.0.100)" + exit 1 + fi + CUSTOM_IP="${ARGS[$next_i]}" + SKIP_NEXT=true ;; --build) BUILD_IMAGES=true ;; --password) next_i=$((i + 1)) @@ -356,6 +375,16 @@ for i in "${!ARGS[@]}"; do esac done +# --- Validate flag combinations --- +if [[ -n "$CUSTOM_IP" ]] && [[ -n "$CUSTOM_DOMAIN" ]]; then + err "--ip and --domain are mutually exclusive. Use --ip for IP-based access, or --domain for domain-based access." + exit 1 +fi +# --ip implies --caddy (browsers require HTTPS for mic/camera access on non-localhost) +if [[ -n "$CUSTOM_IP" ]]; then + USE_CADDY=true +fi + # --- Save CLI args for config memory (re-run without flags) --- if [[ $# -gt 0 ]]; then mkdir -p "$ROOT_DIR/data" @@ -505,6 +534,112 @@ 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: always sync from transcript storage config. + # Endpoint URL must match (changes between Caddy/no-Caddy runs). + 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: synced from transcript storage config" + 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 # ========================================================= @@ -737,13 +872,23 @@ step_server_env() { fi else if [[ -n "$PRIMARY_IP" ]]; then - server_base_url="http://$PRIMARY_IP" + server_base_url="http://$PRIMARY_IP:1250" else server_base_url="http://localhost:1250" fi fi env_set "$SERVER_ENV" "BASE_URL" "$server_base_url" - env_set "$SERVER_ENV" "CORS_ORIGIN" "$server_base_url" + # CORS: allow the frontend origin (port 3000, not the API port) + local cors_origin="${server_base_url}" + if [[ "$USE_CADDY" != "true" ]]; then + # Without Caddy, frontend is on port 3000, API on 1250 + cors_origin="${server_base_url/:1250/:3000}" + # Safety: if substitution didn't change anything, construct explicitly + if [[ "$cors_origin" == "$server_base_url" ]] && [[ -n "$PRIMARY_IP" ]]; then + cors_origin="http://${PRIMARY_IP}:3000" + fi + fi + env_set "$SERVER_ENV" "CORS_ORIGIN" "$cors_origin" # WebRTC: advertise host IP in ICE candidates so browsers can reach the server if [[ -n "$PRIMARY_IP" ]]; then @@ -951,8 +1096,21 @@ step_server_env() { # Hatchet is always required (file, live, and multitrack pipelines all use it) env_set "$SERVER_ENV" "HATCHET_CLIENT_SERVER_URL" "http://hatchet:8888" env_set "$SERVER_ENV" "HATCHET_CLIENT_HOST_PORT" "hatchet:7077" + env_set "$SERVER_ENV" "HATCHET_CLIENT_TLS_STRATEGY" "none" ok "Hatchet connectivity configured (workflow engine for processing pipelines)" + # BIND_HOST controls whether server/web ports are exposed on all interfaces + local root_env="$ROOT_DIR/.env" + touch "$root_env" + if [[ "$USE_CADDY" == "true" ]]; then + # With Caddy, services stay on localhost (Caddy is the public entry point) + env_set "$root_env" "BIND_HOST" "127.0.0.1" + elif [[ -n "$PRIMARY_IP" ]]; then + # Without Caddy + detected IP, expose on all interfaces for direct access + env_set "$root_env" "BIND_HOST" "0.0.0.0" + ok "BIND_HOST=0.0.0.0 (ports exposed for direct access)" + fi + ok "server/.env ready" } @@ -980,18 +1138,26 @@ step_www_env() { base_url="https://localhost" fi else - # No Caddy — user's proxy handles SSL. Use http for now, they'll override. + # No Caddy — clients connect directly to services on their ports. if [[ -n "$PRIMARY_IP" ]]; then - base_url="http://$PRIMARY_IP" + base_url="http://$PRIMARY_IP:3000" else - base_url="http://localhost" + base_url="http://localhost:3000" fi fi + # API_URL: with Caddy, same origin (443 proxies both); without Caddy, API is on port 1250 + local api_url="$base_url" + if [[ "$USE_CADDY" != "true" ]]; then + api_url="${base_url/:3000/:1250}" + # fallback if no port substitution happened (e.g. localhost without port) + [[ "$api_url" == "$base_url" ]] && api_url="${base_url}:1250" + fi + env_set "$WWW_ENV" "SITE_URL" "$base_url" env_set "$WWW_ENV" "NEXTAUTH_URL" "$base_url" env_set "$WWW_ENV" "NEXTAUTH_SECRET" "$NEXTAUTH_SECRET" - env_set "$WWW_ENV" "API_URL" "$base_url" + env_set "$WWW_ENV" "API_URL" "$api_url" env_set "$WWW_ENV" "WEBSOCKET_URL" "auto" env_set "$WWW_ENV" "SERVER_API_URL" "http://server:1250" env_set "$WWW_ENV" "KV_URL" "redis://redis:6379" @@ -1014,14 +1180,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 @@ -1110,7 +1279,13 @@ step_garage() { # Write S3 credentials to server/.env env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_BACKEND" "aws" - env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL" "http://garage:3900" + # Endpoint URL: use public IP when no Caddy so presigned URLs work in the browser. + # With Caddy, internal hostname is fine (Caddy proxies or browser never sees presigned URLs directly). + if [[ "$USE_CADDY" != "true" ]] && [[ -n "$PRIMARY_IP" ]]; then + env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL" "http://${PRIMARY_IP}:3900" + else + env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_ENDPOINT_URL" "http://garage:3900" + fi env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_BUCKET_NAME" "reflector-media" env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_REGION" "garage" if [[ "$created_key" == "true" ]]; then @@ -1188,6 +1363,22 @@ 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 + + local hatchet_proxy_block="" + 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 +1390,7 @@ $CUSTOM_DOMAIN { } handle /health { reverse_proxy server:1250 - } + }${lk_proxy_block}${hatchet_proxy_block} handle { reverse_proxy web:3000 } @@ -1216,7 +1407,7 @@ $CUSTOM_DOMAIN { } handle /health { reverse_proxy server:1250 - } + }${lk_proxy_block}${hatchet_proxy_block} handle { reverse_proxy web:3000 } @@ -1225,17 +1416,19 @@ CADDYEOF ok "Created Caddyfile for $CUSTOM_DOMAIN (Let's Encrypt auto-HTTPS)" elif [[ -n "$PRIMARY_IP" ]]; then # No domain, IP only: catch-all :443 with self-signed cert - # (IP connections don't send SNI, so we can't match by address) + # on_demand generates certs dynamically for any hostname/IP on first request cat > "$caddyfile" << CADDYEOF # Generated by setup-selfhosted.sh — self-signed cert for IP access :443 { - tls internal + tls internal { + on_demand + } handle /v1/* { reverse_proxy server:1250 } handle /health { reverse_proxy server:1250 - } + }${lk_proxy_block}${hatchet_proxy_block} handle { reverse_proxy web:3000 } @@ -1249,21 +1442,8 @@ CADDYEOF ok "Caddyfile already exists" fi - # Add Hatchet dashboard route if Daily.co is detected - if [[ "$DAILY_DETECTED" == "true" ]]; then - if ! grep -q "hatchet" "$caddyfile" 2>/dev/null; then - cat >> "$caddyfile" << CADDYEOF - -# Hatchet workflow dashboard (Daily.co multitrack processing) -:8888 { - tls internal - reverse_proxy hatchet:8888 -} -CADDYEOF - ok "Added Hatchet dashboard route to Caddyfile (port 8888)" - else - ok "Hatchet dashboard route already in Caddyfile" - fi + if [[ "$DAILY_DETECTED" == "true" ]] || [[ "$LIVEKIT_DETECTED" == "true" ]]; then + ok "Hatchet dashboard available at port 8888" fi } @@ -1467,7 +1647,7 @@ step_health() { info "Waiting for Hatchet workflow engine..." local hatchet_ok=false for i in $(seq 1 60); do - if curl -sf http://localhost:8888/api/live > /dev/null 2>&1; then + if compose_cmd exec -T hatchet curl -sf http://localhost:8888/api/live > /dev/null 2>&1; then hatchet_ok=true break fi @@ -1515,7 +1695,7 @@ step_hatchet_token() { # Wait for hatchet to be healthy local hatchet_ok=false for i in $(seq 1 60); do - if curl -sf http://localhost:8888/api/live > /dev/null 2>&1; then + if compose_cmd exec -T hatchet curl -sf http://localhost:8888/api/live > /dev/null 2>&1; then hatchet_ok=true break fi @@ -1586,12 +1766,19 @@ main() { [[ "$BUILD_IMAGES" == "true" ]] && echo " Build: from source" echo "" - # Detect primary IP - PRIMARY_IP="" - if [[ "$OS" == "Linux" ]]; then - PRIMARY_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true) - if [[ "$PRIMARY_IP" == "127."* ]] || [[ -z "$PRIMARY_IP" ]]; then - PRIMARY_IP=$(ip -4 route get 1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' || true) + # Detect primary IP (--ip overrides auto-detection) + if [[ -n "$CUSTOM_IP" ]]; then + PRIMARY_IP="$CUSTOM_IP" + ok "Using provided IP: $PRIMARY_IP" + else + PRIMARY_IP="" + if [[ "$OS" == "Linux" ]]; then + PRIMARY_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true) + if [[ "$PRIMARY_IP" == "127."* ]] || [[ -z "$PRIMARY_IP" ]]; then + PRIMARY_IP=$(ip -4 route get 1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' || true) + fi + elif [[ "$OS" == "Darwin" ]]; then + PRIMARY_IP=$(detect_lan_ip) fi fi @@ -1621,14 +1808,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 +1830,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 @@ -1683,10 +1884,12 @@ EOF echo " App: https://localhost (accept self-signed cert in browser)" echo " API: https://localhost/v1/" fi + elif [[ -n "$PRIMARY_IP" ]]; then + echo " App: http://$PRIMARY_IP:3000" + echo " API: http://$PRIMARY_IP:1250" else - echo " No Caddy — point your reverse proxy at:" - echo " Frontend: web:3000 (or localhost:3000 from host)" - echo " API: server:1250 (or localhost:1250 from host)" + echo " App: http://localhost:3000" + echo " API: http://localhost:1250" fi echo "" if [[ "$HAS_OVERRIDES" == "true" ]]; then @@ -1702,6 +1905,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/db/meetings.py b/server/reflector/db/meetings.py index ba7b8a3a..212877f0 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -165,6 +165,17 @@ class MeetingController: results = await get_database().fetch_all(query) return [Meeting(**result) for result in results] + async def get_all_inactive_livekit(self) -> list[Meeting]: + """Get inactive LiveKit meetings (for multitrack processing discovery).""" + query = meetings.select().where( + sa.and_( + meetings.c.is_active == sa.false(), + meetings.c.platform == "livekit", + ) + ) + results = await get_database().fetch_all(query) + return [Meeting(**result) for result in results] + async def get_by_room_name( self, room_name: str, diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py index d903c263..3d137413 100644 --- a/server/reflector/db/transcripts.py +++ b/server/reflector/db/transcripts.py @@ -486,6 +486,14 @@ class TranscriptController: return None return Transcript(**result) + async def get_by_meeting_id(self, meeting_id: str) -> Transcript | None: + """Get a transcript by meeting_id (first match).""" + query = transcripts.select().where(transcripts.c.meeting_id == meeting_id) + result = await get_database().fetch_one(query) + if not result: + return None + return Transcript(**result) + async def get_by_recording_id( self, recording_id: str, **kwargs ) -> Transcript | None: diff --git a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py index ef8a5c16..8fc912ed 100644 --- a/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py +++ b/server/reflector/hatchet/workflows/daily_multitrack_pipeline.py @@ -273,8 +273,10 @@ def with_error_handling( ) @with_error_handling(TaskName.GET_RECORDING) async def get_recording(input: PipelineInput, ctx: Context) -> RecordingResult: - """Fetch recording metadata from Daily.co API.""" - ctx.log(f"get_recording: starting for recording_id={input.recording_id}") + """Fetch recording metadata. Platform-aware: Daily calls API, LiveKit skips.""" + ctx.log( + f"get_recording: starting for recording_id={input.recording_id}, platform={input.source_platform}" + ) ctx.log( f"get_recording: transcript_id={input.transcript_id}, room_id={input.room_id}" ) @@ -299,6 +301,18 @@ async def get_recording(input: PipelineInput, ctx: Context) -> RecordingResult: ) ctx.log(f"get_recording: status set to 'processing' and broadcasted") + # LiveKit: no external API call needed — metadata comes from S3 track listing + if input.source_platform == "livekit": + ctx.log( + "get_recording: LiveKit platform — skipping API call (metadata from S3)" + ) + return RecordingResult( + id=input.recording_id, + mtg_session_id=None, + duration=0, # Duration calculated from tracks later + ) + + # Daily.co: fetch recording metadata from API if not settings.DAILY_API_KEY: ctx.log("get_recording: ERROR - DAILY_API_KEY not configured") raise ValueError("DAILY_API_KEY not configured") @@ -332,11 +346,12 @@ async def get_recording(input: PipelineInput, ctx: Context) -> RecordingResult: ) @with_error_handling(TaskName.GET_PARTICIPANTS) async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsResult: - """Fetch participant list from Daily.co API and update transcript in database.""" - ctx.log(f"get_participants: transcript_id={input.transcript_id}") + """Fetch participant list and update transcript. Platform-aware.""" + ctx.log( + f"get_participants: transcript_id={input.transcript_id}, platform={input.source_platform}" + ) recording = ctx.task_output(get_recording) - mtg_session_id = recording.mtg_session_id async with fresh_db_connection(): from reflector.db.transcripts import ( # noqa: PLC0415 TranscriptDuration, @@ -347,8 +362,8 @@ async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsRe transcript = await transcripts_controller.get_by_id(input.transcript_id) if not transcript: raise ValueError(f"Transcript {input.transcript_id} not found") - # Note: title NOT cleared - preserves existing titles - # Duration from Daily API (seconds -> milliseconds) - master source + + # Duration from recording metadata (seconds -> milliseconds) duration_ms = recording.duration * 1000 if recording.duration else 0 await transcripts_controller.update( transcript, @@ -360,65 +375,141 @@ async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsRe }, ) - await append_event_and_broadcast( - input.transcript_id, - transcript, - "DURATION", - TranscriptDuration(duration=duration_ms), - logger=logger, - ) - - mtg_session_id = assert_non_none_and_non_empty( - mtg_session_id, "mtg_session_id is required" - ) - daily_api_key = assert_non_none_and_non_empty( - settings.DAILY_API_KEY, "DAILY_API_KEY is required" - ) - - async with DailyApiClient( - api_key=daily_api_key, base_url=settings.DAILY_API_URL - ) as client: - participants = await client.get_meeting_participants(mtg_session_id) - - id_to_name = {} - id_to_user_id = {} - for p in participants.data: - if p.user_name: - id_to_name[p.participant_id] = p.user_name - if p.user_id: - id_to_user_id[p.participant_id] = p.user_id - - track_keys = [t["s3_key"] for t in input.tracks] - cam_audio_keys = filter_cam_audio_tracks(track_keys) + if duration_ms: + await append_event_and_broadcast( + input.transcript_id, + transcript, + "DURATION", + TranscriptDuration(duration=duration_ms), + logger=logger, + ) participants_list: list[ParticipantInfo] = [] - for idx, key in enumerate(cam_audio_keys): + + if input.source_platform == "livekit": + # LiveKit: participant identity is in the track dict or can be parsed from filepath + from reflector.utils.livekit import ( + parse_livekit_track_filepath, # noqa: PLC0415 + ) + + # Look up identity → Reflector user_id mapping from Redis + # (stored at join time in rooms.py) + identity_to_user_id: dict[str, str] = {} try: - parsed = parse_daily_recording_filename(key) - participant_id = parsed.participant_id - except ValueError as e: - logger.error( - "Failed to parse Daily recording filename", - error=str(e), - key=key, + from reflector.db.meetings import ( + meetings_controller as mc, # noqa: PLC0415 + ) + from reflector.redis_cache import ( + get_async_redis_client, # noqa: PLC0415 ) - continue - default_name = f"Speaker {idx}" - name = id_to_name.get(participant_id, default_name) - user_id = id_to_user_id.get(participant_id) + meeting = ( + await mc.get_by_id(transcript.meeting_id) + if transcript.meeting_id + else None + ) + if meeting: + redis_client = await get_async_redis_client() + mapping_key = f"livekit:participant_map:{meeting.room_name}" + raw_map = await redis_client.hgetall(mapping_key) + identity_to_user_id = { + k.decode() if isinstance(k, bytes) else k: v.decode() + if isinstance(v, bytes) + else v + for k, v in raw_map.items() + } + ctx.log( + f"get_participants: loaded {len(identity_to_user_id)} identity→user_id mappings from Redis" + ) + except Exception as e: + ctx.log( + f"get_participants: could not load identity map from Redis: {e}" + ) - participant = TranscriptParticipant( - id=participant_id, speaker=idx, name=name, user_id=user_id - ) - await transcripts_controller.upsert_participant(transcript, participant) - participants_list.append( - ParticipantInfo( - participant_id=participant_id, - user_name=name, + for idx, track in enumerate(input.tracks): + identity = track.get("participant_identity") + if not identity: + # Reprocess path: parse from S3 key + try: + parsed = parse_livekit_track_filepath(track["s3_key"]) + identity = parsed.participant_identity + except (ValueError, KeyError): + identity = f"speaker-{idx}" + + # Strip the uuid suffix from identity for display name + # e.g., "Juan-2bcea0" → "Juan" + display_name = ( + identity.rsplit("-", 1)[0] if "-" in identity else identity + ) + reflector_user_id = identity_to_user_id.get(identity) + + participant = TranscriptParticipant( + id=identity, speaker=idx, + name=display_name, + user_id=reflector_user_id, ) + await transcripts_controller.upsert_participant(transcript, participant) + participants_list.append( + ParticipantInfo( + participant_id=identity, + user_name=display_name, + speaker=idx, + ) + ) + else: + # Daily.co: fetch participant names from API + mtg_session_id = recording.mtg_session_id + mtg_session_id = assert_non_none_and_non_empty( + mtg_session_id, "mtg_session_id is required" ) + daily_api_key = assert_non_none_and_non_empty( + settings.DAILY_API_KEY, "DAILY_API_KEY is required" + ) + + async with DailyApiClient( + api_key=daily_api_key, base_url=settings.DAILY_API_URL + ) as client: + participants = await client.get_meeting_participants(mtg_session_id) + + id_to_name = {} + id_to_user_id = {} + for p in participants.data: + if p.user_name: + id_to_name[p.participant_id] = p.user_name + if p.user_id: + id_to_user_id[p.participant_id] = p.user_id + + track_keys = [t["s3_key"] for t in input.tracks] + cam_audio_keys = filter_cam_audio_tracks(track_keys) + + for idx, key in enumerate(cam_audio_keys): + try: + parsed = parse_daily_recording_filename(key) + participant_id = parsed.participant_id + except ValueError as e: + logger.error( + "Failed to parse Daily recording filename", + error=str(e), + key=key, + ) + continue + + default_name = f"Speaker {idx}" + name = id_to_name.get(participant_id, default_name) + user_id = id_to_user_id.get(participant_id) + + participant = TranscriptParticipant( + id=participant_id, speaker=idx, name=name, user_id=user_id + ) + await transcripts_controller.upsert_participant(transcript, participant) + participants_list.append( + ParticipantInfo( + participant_id=participant_id, + user_name=name, + speaker=idx, + ) + ) ctx.log(f"get_participants complete: {len(participants_list)} participants") @@ -440,11 +531,66 @@ async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsRe @with_error_handling(TaskName.PROCESS_TRACKS) async def process_tracks(input: PipelineInput, ctx: Context) -> ProcessTracksResult: """Spawn child workflows for each track (dynamic fan-out).""" - ctx.log(f"process_tracks: spawning {len(input.tracks)} track workflows") + ctx.log( + f"process_tracks: spawning {len(input.tracks)} track workflows, platform={input.source_platform}" + ) participants_result = ctx.task_output(get_participants) source_language = participants_result.source_language + # For LiveKit: calculate padding offsets from filename timestamps. + # OGG files don't have embedded start_time metadata, so we pre-calculate. + track_padding: dict[int, float] = {} + if input.source_platform == "livekit": + from datetime import datetime # noqa: PLC0415 + + from reflector.utils.livekit import ( + parse_livekit_track_filepath, # noqa: PLC0415 + ) + + timestamps = [] + for i, track in enumerate(input.tracks): + ts_str = track.get("timestamp") + if ts_str: + try: + ts = datetime.fromisoformat(ts_str) + timestamps.append((i, ts)) + except (ValueError, TypeError): + ctx.log( + f"process_tracks: could not parse timestamp for track {i}: {ts_str}" + ) + timestamps.append((i, None)) + else: + # Reprocess path: parse timestamp from S3 key + try: + parsed = parse_livekit_track_filepath(track["s3_key"]) + timestamps.append((i, parsed.timestamp)) + ctx.log( + f"process_tracks: parsed timestamp from S3 key for track {i}: {parsed.timestamp}" + ) + except (ValueError, KeyError): + timestamps.append((i, None)) + + valid_timestamps = [(i, ts) for i, ts in timestamps if ts is not None] + if valid_timestamps: + earliest = min(ts for _, ts in valid_timestamps) + # LiveKit Track Egress outputs OGG/Opus files, but the transcription + # service only accepts WebM. The padding step converts OGG→WebM as a + # side effect of applying the adelay filter. For the earliest track + # (offset=0), we use a minimal padding to force this conversion. + LIVEKIT_MIN_PADDING_SECONDS = ( + 0.001 # 1ms — inaudible, forces OGG→WebM conversion + ) + + for i, ts in valid_timestamps: + offset = (ts - earliest).total_seconds() + if offset == 0.0: + offset = LIVEKIT_MIN_PADDING_SECONDS + track_padding[i] = offset + ctx.log( + f"process_tracks: track {i} padding={offset}s (from filename timestamp)" + ) + bulk_runs = [ track_workflow.create_bulk_run_item( input=TrackInput( @@ -454,6 +600,7 @@ async def process_tracks(input: PipelineInput, ctx: Context) -> ProcessTracksRes transcript_id=input.transcript_id, language=source_language, source_platform=input.source_platform, + padding_seconds=track_padding.get(i), ) ) for i, track in enumerate(input.tracks) @@ -605,13 +752,31 @@ async def mixdown_tracks(input: PipelineInput, ctx: Context) -> MixdownResult: # else: modal backend already uploaded to output_url async with fresh_db_connection(): - from reflector.db.transcripts import transcripts_controller # noqa: PLC0415 + from reflector.db.transcripts import ( # noqa: PLC0415 + TranscriptDuration, + transcripts_controller, + ) transcript = await transcripts_controller.get_by_id(input.transcript_id) if transcript: - await transcripts_controller.update( - transcript, {"audio_location": "storage"} - ) + update_data = {"audio_location": "storage"} + # Set duration from mixdown if not already set (LiveKit: duration starts at 0) + if not transcript.duration or transcript.duration == 0: + update_data["duration"] = result.duration_ms + await transcripts_controller.update(transcript, update_data) + + # Broadcast duration update if it was missing + if not transcript.duration or transcript.duration == 0: + await append_event_and_broadcast( + input.transcript_id, + transcript, + "DURATION", + TranscriptDuration(duration=result.duration_ms), + logger=logger, + ) + ctx.log( + f"mixdown_tracks: set duration={result.duration_ms}ms from mixdown" + ) ctx.log(f"mixdown_tracks complete: {result.size} bytes to {storage_path}") diff --git a/server/reflector/hatchet/workflows/track_processing.py b/server/reflector/hatchet/workflows/track_processing.py index 2458ee0c..b2b477f2 100644 --- a/server/reflector/hatchet/workflows/track_processing.py +++ b/server/reflector/hatchet/workflows/track_processing.py @@ -37,6 +37,9 @@ class TrackInput(BaseModel): transcript_id: str language: str = "en" source_platform: str = "daily" + # Pre-calculated padding in seconds (from filename timestamps for LiveKit). + # When set, overrides container metadata extraction for start_time. + padding_seconds: float | None = None hatchet = HatchetClientManager.get_client() @@ -53,15 +56,19 @@ track_workflow = hatchet.workflow(name="TrackProcessing", input_validator=TrackI async def pad_track(input: TrackInput, ctx: Context) -> PadTrackResult: """Pad single audio track with silence for alignment. - Extracts stream.start_time from WebM container metadata and applies - silence padding using PyAV filter graph (adelay). + For Daily: extracts stream.start_time from WebM container metadata. + For LiveKit: uses pre-calculated padding_seconds from filename timestamps + (OGG files don't have embedded start_time metadata). """ - ctx.log(f"pad_track: track {input.track_index}, s3_key={input.s3_key}") + ctx.log( + f"pad_track: track {input.track_index}, s3_key={input.s3_key}, padding_seconds={input.padding_seconds}" + ) logger.info( "[Hatchet] pad_track", track_index=input.track_index, s3_key=input.s3_key, transcript_id=input.transcript_id, + padding_seconds=input.padding_seconds, ) try: @@ -79,10 +86,16 @@ async def pad_track(input: TrackInput, ctx: Context) -> PadTrackResult: bucket=input.bucket_name, ) - with av.open(source_url) as in_container: - start_time_seconds = extract_stream_start_time_from_container( - in_container, input.track_index, logger=logger - ) + if input.padding_seconds is not None: + # Pre-calculated offset (LiveKit: from filename timestamps) + start_time_seconds = input.padding_seconds + ctx.log(f"pad_track: using pre-calculated padding={start_time_seconds}s") + else: + # Extract from container metadata (Daily: WebM start_time) + with av.open(source_url) as in_container: + start_time_seconds = extract_stream_start_time_from_container( + in_container, input.track_index, logger=logger + ) # If no padding needed, return original S3 key if start_time_seconds <= 0: 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..963482e8 --- /dev/null +++ b/server/reflector/livekit_api/client.py @@ -0,0 +1,195 @@ +""" +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, + AutoTrackEgress, + CreateRoomRequest, + DeleteRoomRequest, + DirectFileOutput, + EgressInfo, + ListEgressRequest, + ListParticipantsRequest, + LiveKitAPI, + Room, + RoomEgress, + 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, + enable_auto_track_egress: bool = False, + track_egress_filepath: str = "livekit/{room_name}/{publisher_identity}-{time}", + ) -> 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. + enable_auto_track_egress: If True, automatically record each participant's + audio track to S3 as a separate file (OGG/Opus). + track_egress_filepath: S3 filepath template for auto track egress. + Supports {room_name}, {publisher_identity}, {time}. + """ + egress = None + if enable_auto_track_egress: + egress = RoomEgress( + tracks=AutoTrackEgress( + filepath=track_egress_filepath, + s3=self._build_s3_upload(), + ), + ) + + req = CreateRoomRequest( + name=name, + empty_timeout=empty_timeout, + max_participants=max_participants, + egress=egress, + ) + 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..59605c7b --- /dev/null +++ b/server/reflector/livekit_api/webhooks.py @@ -0,0 +1,52 @@ +""" +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. + Logs at different levels depending on failure type: + - WARNING: invalid signature, expired token, malformed JWT (expected rejections) + - ERROR: unexpected exceptions (potential bugs or attacks) + """ + if isinstance(body, bytes): + body = body.decode("utf-8") + try: + return receiver.receive(body, auth_header) + except (ValueError, KeyError) as e: + # Expected verification failures (bad JWT, wrong key, expired, malformed) + logger.warning( + "LiveKit webhook verification failed", + error=str(e), + error_type=type(e).__name__, + ) + return None + except Exception as e: + # Unexpected errors — log at ERROR for visibility (potential attack or SDK bug) + logger.error( + "Unexpected error during LiveKit webhook verification", + error=str(e), + error_type=type(e).__name__, + exc_info=True, + ) + 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/services/transcript_process.py b/server/reflector/services/transcript_process.py index d15df299..b4040753 100644 --- a/server/reflector/services/transcript_process.py +++ b/server/reflector/services/transcript_process.py @@ -155,12 +155,17 @@ async def prepare_transcript_processing(validation: ValidationOk) -> PrepareResu ) if track_keys: + # Detect platform from recording ID prefix + source_platform = ( + "livekit" if recording_id and recording_id.startswith("lk-") else "daily" + ) return MultitrackProcessingConfig( bucket_name=bucket_name, # type: ignore (validated above) track_keys=track_keys, transcript_id=validation.transcript_id, recording_id=recording_id, room_id=validation.room_id, + source_platform=source_platform, ) return FileProcessingConfig( 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/utils/livekit.py b/server/reflector/utils/livekit.py new file mode 100644 index 00000000..de05967a --- /dev/null +++ b/server/reflector/utils/livekit.py @@ -0,0 +1,112 @@ +""" +LiveKit track file utilities. + +Parse participant identity and timing from Auto Track Egress S3 filepaths. + +Actual filepath format from LiveKit Auto Track Egress: + livekit/{room_name}/{publisher_identity}-{ISO_timestamp}-{track_id}.{ext} + +Examples: + livekit/myroom-20260401172036/juan-4b82ed-2026-04-01T195758-TR_AMR3SWs74Divho.ogg + livekit/myroom-20260401172036/juan2-63abcf-2026-04-01T195847-TR_AMyoSbM7tAQbYj.ogg + livekit/myroom-20260401172036/EG_K5sipvfB5fTM.json (manifest, skip) + livekit/myroom-20260401172036/juan-4b82ed-2026-04-01T195727-TR_VC679dgMQBdfhT.webm (video, skip) +""" + +import re +from dataclasses import dataclass +from datetime import datetime, timezone + +from reflector.utils.string import NonEmptyString + + +@dataclass +class LiveKitTrackFile: + """Parsed info from a LiveKit track egress filepath.""" + + s3_key: str + room_name: str + participant_identity: str + timestamp: datetime # Parsed from ISO timestamp in filename + track_id: str # LiveKit track ID (e.g., TR_AMR3SWs74Divho) + + +# Pattern: livekit/{room_name}/{identity}-{ISO_date}T{time}-{track_id}.{ext} +# The identity can contain alphanumeric, hyphens, underscores +# ISO timestamp is like 2026-04-01T195758 +# Track ID starts with TR_ +_TRACK_FILENAME_PATTERN = re.compile( + r"^livekit/(?P[^/]+)/(?P.+?)-(?P\d{4}-\d{2}-\d{2}T\d{6})-(?PTR_\w+)\.(?P\w+)$" +) + + +def parse_livekit_track_filepath(s3_key: str) -> LiveKitTrackFile: + """Parse a LiveKit track egress filepath into components. + + Args: + s3_key: S3 key like 'livekit/myroom-20260401/juan-4b82ed-2026-04-01T195758-TR_AMR3SWs74Divho.ogg' + + Returns: + LiveKitTrackFile with parsed components. + + Raises: + ValueError: If the filepath doesn't match the expected format. + """ + match = _TRACK_FILENAME_PATTERN.match(s3_key) + if not match: + raise ValueError( + f"LiveKit track filepath doesn't match expected format: {s3_key}" + ) + + # Parse ISO-ish timestamp (e.g., 2026-04-01T195758 → datetime) + ts_str = match.group("timestamp") + try: + ts = datetime.strptime(ts_str, "%Y-%m-%dT%H%M%S").replace(tzinfo=timezone.utc) + except ValueError: + raise ValueError(f"Cannot parse timestamp '{ts_str}' from: {s3_key}") + + return LiveKitTrackFile( + s3_key=s3_key, + room_name=match.group("room_name"), + participant_identity=match.group("identity"), + timestamp=ts, + track_id=match.group("track_id"), + ) + + +def filter_audio_tracks(s3_keys: list[str]) -> list[str]: + """Filter S3 keys to only audio tracks (.ogg), excluding manifests and video.""" + return [k for k in s3_keys if k.endswith(".ogg")] + + +def calculate_track_offsets( + tracks: list[LiveKitTrackFile], +) -> list[tuple[LiveKitTrackFile, float]]: + """Calculate silence padding offset for each track. + + The earliest track starts at time zero. Each subsequent track + gets (track_timestamp - earliest_timestamp) seconds of silence prepended. + + Returns: + List of (track, offset_seconds) tuples. + """ + if not tracks: + return [] + + earliest = min(t.timestamp for t in tracks) + return [(t, (t.timestamp - earliest).total_seconds()) for t in tracks] + + +def extract_livekit_base_room_name(livekit_room_name: str) -> NonEmptyString: + """Extract base room name from LiveKit timestamped room name. + + LiveKit rooms use the same naming as Daily: {base_name}-YYYYMMDDHHMMSS + """ + base_name = livekit_room_name.rsplit("-", 1)[0] + assert base_name, f"Extracted base name is empty from: {livekit_room_name}" + return NonEmptyString(base_name) + + +def recording_lock_key(room_name: str) -> str: + """Redis lock key for preventing duplicate processing.""" + return f"livekit:processing:{room_name}" 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..babc3eed --- /dev/null +++ b/server/reflector/video_platforms/livekit.py @@ -0,0 +1,192 @@ +""" +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 + + # Enable auto track egress for cloud recording (per-participant audio to S3). + # Gracefully degrade if S3 credentials are missing — room still works, just no recording. + enable_recording = room.recording_type == "cloud" + egress_enabled = False + if enable_recording: + try: + self._api_client._build_s3_upload() # Validate credentials exist + egress_enabled = True + except ValueError: + logger.warning( + "S3 credentials not configured — room created without auto track egress. " + "Set LIVEKIT_STORAGE_AWS_* to enable recording.", + room_name=room_name, + ) + + lk_room = await self._api_client.create_room( + name=room_name, + empty_timeout=empty_timeout, + enable_auto_track_egress=egress_enabled, + ) + + logger.info( + "LiveKit room created", + room_name=lk_room.name, + room_sid=lk_room.sid, + empty_timeout=empty_timeout, + auto_track_egress=egress_enabled, + ) + + # 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..2f031229 --- /dev/null +++ b/server/reflector/views/livekit.py @@ -0,0 +1,246 @@ +"""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. + +Webhooks are used as fast-path triggers and logging. Track discovery +for the multitrack pipeline uses S3 listing (source of truth), not +webhook data. +""" + +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": + await _handle_room_finished(event) + 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 + logger.info( + "Egress started", + room_name=egress.room_name if egress else None, + egress_id=egress.egress_id if egress else None, + ) + + +async def _handle_egress_ended(event): + """Handle Track Egress completion. Delete video files immediately to save storage. + + AutoTrackEgress records ALL tracks (audio + video). Audio is kept for the + transcription pipeline. Video files are unused and deleted on completion. + This saves ~50x storage (video is 98% of egress output for HD cameras). + """ + egress = event.egress_info + if not egress: + logger.warning("egress_ended: no egress info in payload") + return + + # EGRESS_FAILED = 4 + if egress.status == 4: + logger.error( + "Egress failed", + room_name=egress.room_name, + egress_id=egress.egress_id, + error=egress.error, + ) + return + + file_results = list(egress.file_results) + logger.info( + "Egress ended", + room_name=egress.room_name, + egress_id=egress.egress_id, + status=egress.status, + num_files=len(file_results), + filenames=[f.filename for f in file_results] if file_results else [], + ) + + # Delete video files (.webm) immediately — only audio (.ogg) is needed for transcription. + # Video tracks are 50-90x larger than audio and unused by the pipeline. + # JSON manifests are kept (lightweight metadata, ~430 bytes each). + for file_result in file_results: + filename = file_result.filename + if filename and filename.endswith(".webm"): + try: + from reflector.storage import get_source_storage # noqa: PLC0415 + + storage = get_source_storage("livekit") + await storage.delete_file(filename) + logger.info( + "Deleted video egress file", + filename=filename, + room_name=egress.room_name, + ) + except Exception as e: + # Non-critical — pipeline filters these out anyway + logger.warning( + "Failed to delete video egress file", + filename=filename, + error=str(e), + ) + + +async def _handle_room_finished(event): + """Fast-path: trigger multitrack processing when room closes. + + This is an optimization — if missed, the process_livekit_ended_meetings + beat task catches it within ~2 minutes. + """ + room_name = event.room.name if event.room else None + if not room_name: + logger.warning("room_finished: no room name in payload") + return + + logger.info("Room finished", room_name=room_name) + + meeting = await meetings_controller.get_by_room_name(room_name) + if not meeting: + logger.warning("room_finished: meeting not found", room_name=room_name) + return + + # Deactivate the meeting — LiveKit room is destroyed, so process_meetings + # can't detect this via API (list_participants returns empty for deleted rooms). + if meeting.is_active: + await meetings_controller.update_meeting(meeting.id, is_active=False) + logger.info("room_finished: meeting deactivated", meeting_id=meeting.id) + + # Import here to avoid circular imports (worker imports views) + from reflector.worker.process import process_livekit_multitrack + + process_livekit_multitrack.delay( + room_name=room_name, + meeting_id=meeting.id, + ) + + logger.info( + "room_finished: queued multitrack processing", + meeting_id=meeting.id, + room_name=room_name, + ) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 7a51ef41..3ba55f63 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -554,6 +554,7 @@ async def rooms_join_meeting( room_name: str, meeting_id: str, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + display_name: str | None = None, ): user_id = user["sub"] if user else None room = await rooms_controller.get_by_name(room_name) @@ -598,4 +599,51 @@ async def rooms_join_meeting( meeting = meeting.model_copy() meeting.room_url = add_query_param(meeting.room_url, "t", token) + elif meeting.platform == "livekit": + import re + import uuid + + client = create_platform_client(meeting.platform) + # Identity must be unique per participant to avoid S3 key collisions. + # Format: {readable_name}-{short_uuid} ensures uniqueness even for same names. + uid_suffix = uuid.uuid4().hex[:6] + if display_name: + safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", display_name.strip())[:40] + participant_identity = ( + f"{safe_name}-{uid_suffix}" if safe_name else f"anon-{uid_suffix}" + ) + elif user_id: + email = getattr(user, "email", None) + if email and "@" in email: + participant_identity = f"{email.split('@')[0]}-{uid_suffix}" + else: + participant_identity = f"{user_id[:12]}-{uid_suffix}" + else: + participant_identity = f"anon-{uid_suffix}" + participant_name = display_name or participant_identity + + # Store identity → Reflector user_id mapping for the pipeline + # (so TranscriptParticipant.user_id can be set correctly) + if user_id: + from reflector.redis_cache import get_async_redis_client # noqa: PLC0415 + + redis_client = await get_async_redis_client() + mapping_key = f"livekit:participant_map:{meeting.room_name}" + await redis_client.hset(mapping_key, participant_identity, user_id) + await redis_client.expire(mapping_key, 7 * 86400) # 7 day TTL + + 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, + ) + # Close the platform client to release aiohttp session + if hasattr(client, "close"): + await client.close() + + 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..5e2f730f 100644 --- a/server/reflector/worker/app.py +++ b/server/reflector/worker/app.py @@ -83,7 +83,25 @@ 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: + beat_schedule["process_livekit_ended_meetings"] = { + "task": "reflector.worker.process.process_livekit_ended_meetings", + "schedule": 120, # Every 2 minutes + } + beat_schedule["reprocess_failed_livekit_recordings"] = { + "task": "reflector.worker.process.reprocess_failed_livekit_recordings", + "schedule": crontab(hour=5, minute=0), + } + logger.info( + "LiveKit beat tasks enabled", + tasks=[ + "process_livekit_ended_meetings", + "reprocess_failed_livekit_recordings", + ], + ) + + _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/reflector/worker/process.py b/server/reflector/worker/process.py index aa7d8042..a9d31c74 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -874,6 +874,22 @@ async def process_meetings(): logger_.info( "Meeting deactivated - scheduled time ended with no participants", ) + elif meeting.platform == "livekit" and not has_had_sessions: + # LiveKit rooms are destroyed after empty_timeout. Once gone, + # list_participants returns [] — indistinguishable from "never used". + # Check if meeting was created >10 min ago; if so, assume room is gone. + meeting_start = meeting.start_date + if meeting_start.tzinfo is None: + meeting_start = meeting_start.replace(tzinfo=timezone.utc) + age_minutes = (current_time - meeting_start).total_seconds() / 60 + if age_minutes > 10: + should_deactivate = True + logger_.info( + "LiveKit meeting deactivated - room likely destroyed (no sessions after 10 min)", + age_minutes=round(age_minutes, 1), + ) + else: + logger_.debug("LiveKit meeting still young, keep it") else: logger_.debug("Meeting not yet started, keep it") @@ -1170,3 +1186,311 @@ async def trigger_daily_reconciliation() -> None: except Exception as e: logger.error("Reconciliation trigger failed", error=str(e), exc_info=True) + + +# ============================================================ +# LiveKit multitrack recording tasks +# ============================================================ + + +@shared_task +@asynctask +async def process_livekit_multitrack( + room_name: str, + meeting_id: str, +): + """ + Process LiveKit multitrack recording by discovering tracks on S3. + + Tracks are discovered via S3 listing (source of truth), not webhooks. + Called from room_finished webhook (fast-path) or beat task (fallback). + """ + from reflector.utils.livekit import ( # noqa: PLC0415 + recording_lock_key, + ) + + logger.info( + "Processing LiveKit multitrack recording", + room_name=room_name, + meeting_id=meeting_id, + ) + + lock_key = recording_lock_key(room_name) + async with RedisAsyncLock( + key=lock_key, + timeout=600, + extend_interval=60, + skip_if_locked=True, + blocking=False, + ) as lock: + if not lock.acquired: + logger.warning( + "LiveKit processing skipped - lock already held", + room_name=room_name, + lock_key=lock_key, + ) + return + + await _process_livekit_multitrack_inner(room_name, meeting_id) + + +async def _process_livekit_multitrack_inner( + room_name: str, + meeting_id: str, +): + """Inner processing logic for LiveKit multitrack recording.""" + # 1. Discover tracks by listing S3 prefix. + # Wait briefly for egress files to finish flushing to S3 — the room_finished + # webhook fires after empty_timeout, but egress finalization may still be in progress. + import asyncio as _asyncio # noqa: PLC0415 + + from reflector.storage import get_source_storage # noqa: PLC0415 + from reflector.utils.livekit import ( # noqa: PLC0415 + extract_livekit_base_room_name, + filter_audio_tracks, + parse_livekit_track_filepath, + ) + + EGRESS_FLUSH_DELAY = 10 # seconds — egress typically flushes within a few seconds + EGRESS_RETRY_DELAY = 30 # seconds — retry if first listing finds nothing + + await _asyncio.sleep(EGRESS_FLUSH_DELAY) + + storage = get_source_storage("livekit") + s3_prefix = f"livekit/{room_name}/" + all_keys = await storage.list_objects(prefix=s3_prefix) + + # Filter to audio tracks only (.ogg) — skip .json manifests and .webm video + audio_keys = filter_audio_tracks(all_keys) if all_keys else [] + + if not audio_keys: + # Retry once after a longer delay — egress may still be flushing + logger.info( + "No audio tracks found yet, retrying after delay", + room_name=room_name, + retry_delay=EGRESS_RETRY_DELAY, + ) + await _asyncio.sleep(EGRESS_RETRY_DELAY) + all_keys = await storage.list_objects(prefix=s3_prefix) + audio_keys = filter_audio_tracks(all_keys) if all_keys else [] + + # Sanity check: compare audio tracks against egress manifests. + # Each Track Egress (audio or video) produces a .json manifest. + # Video tracks produce .webm files. So expected audio count ≈ manifests - video files. + if all_keys: + manifest_count = sum(1 for k in all_keys if k.endswith(".json")) + video_count = sum(1 for k in all_keys if k.endswith(".webm")) + expected_audio = manifest_count - video_count + if expected_audio > len(audio_keys) and expected_audio > 0: + # Some audio tracks may still be flushing — wait and retry + logger.info( + "Expected more audio tracks based on manifests, waiting for late flushes", + room_name=room_name, + expected=expected_audio, + found=len(audio_keys), + ) + await _asyncio.sleep(EGRESS_RETRY_DELAY) + all_keys = await storage.list_objects(prefix=s3_prefix) + audio_keys = filter_audio_tracks(all_keys) if all_keys else [] + + logger.info( + "S3 track discovery complete", + room_name=room_name, + total_files=len(all_keys) if all_keys else 0, + audio_files=len(audio_keys), + ) + + if not audio_keys: + logger.warning( + "No audio track files found on S3 after retries", + room_name=room_name, + s3_prefix=s3_prefix, + ) + return + + # 2. Parse track info from filenames + parsed_tracks = [] + for key in audio_keys: + try: + parsed = parse_livekit_track_filepath(key) + parsed_tracks.append(parsed) + except ValueError as e: + logger.warning("Skipping unparseable track file", s3_key=key, error=str(e)) + + if not parsed_tracks: + logger.warning( + "No valid track files found after parsing", + room_name=room_name, + raw_keys=all_keys, + ) + return + + track_keys = [t.s3_key for t in parsed_tracks] + + # 3. Find meeting and room + meeting = await meetings_controller.get_by_id(meeting_id) + if not meeting: + logger.error( + "Meeting not found for LiveKit recording", + meeting_id=meeting_id, + room_name=room_name, + ) + return + + base_room_name = extract_livekit_base_room_name(room_name) + room = await rooms_controller.get_by_name(base_room_name) + if not room: + logger.error("Room not found", room_name=base_room_name) + return + + # 4. Create recording + recording_id = f"lk-{room_name}" + bucket_name = settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME or "" + + existing_recording = await recordings_controller.get_by_id(recording_id) + if existing_recording and existing_recording.deleted_at is not None: + logger.info("Skipping soft-deleted recording", recording_id=recording_id) + return + + if not existing_recording: + recording = await recordings_controller.create( + Recording( + id=recording_id, + bucket_name=bucket_name, + object_key=s3_prefix, + recorded_at=datetime.now(timezone.utc), + meeting_id=meeting.id, + track_keys=track_keys, + ) + ) + else: + recording = existing_recording + + # 5. Create or get transcript + transcript = await transcripts_controller.get_by_recording_id(recording.id) + if transcript and transcript.deleted_at is not None: + logger.info("Skipping soft-deleted transcript", recording_id=recording.id) + return + if not transcript: + transcript = await transcripts_controller.add( + "", + source_kind=SourceKind.ROOM, + source_language="en", + target_language="en", + user_id=room.user_id, + recording_id=recording.id, + share_mode="semi-private", + meeting_id=meeting.id, + room_id=room.id, + ) + + # 6. Start Hatchet pipeline (reuses DiarizationPipeline with source_platform="livekit") + workflow_id = await HatchetClientManager.start_workflow( + workflow_name="DiarizationPipeline", + input_data={ + "recording_id": recording_id, + "tracks": [ + { + "s3_key": t.s3_key, + "participant_identity": t.participant_identity, + "timestamp": t.timestamp.isoformat(), + } + for t in parsed_tracks + ], + "bucket_name": bucket_name, + "transcript_id": transcript.id, + "room_id": room.id, + "source_platform": "livekit", + }, + additional_metadata={ + "transcript_id": transcript.id, + "recording_id": recording_id, + }, + ) + logger.info( + "Started LiveKit Hatchet workflow", + workflow_id=workflow_id, + transcript_id=transcript.id, + room_name=room_name, + num_tracks=len(parsed_tracks), + ) + + await transcripts_controller.update(transcript, {"workflow_run_id": workflow_id}) + + +@shared_task +@asynctask +async def process_livekit_ended_meetings(): + """Check for inactive LiveKit meetings that need multitrack processing. + + Runs on a beat schedule. Catches cases where room_finished webhook was missed. + Only processes meetings that: + - Platform is "livekit" + - is_active=False (already deactivated by process_meetings) + - No associated transcript yet + """ + from reflector.db.transcripts import transcripts_controller as tc # noqa: PLC0415 + + all_livekit = await meetings_controller.get_all_inactive_livekit() + + queued = 0 + for meeting in all_livekit: + # Skip if already has a transcript + existing = await tc.get_by_meeting_id(meeting.id) + if existing: + continue + + logger.info( + "Found unprocessed inactive LiveKit meeting", + meeting_id=meeting.id, + room_name=meeting.room_name, + ) + + process_livekit_multitrack.delay( + room_name=meeting.room_name, + meeting_id=meeting.id, + ) + queued += 1 + + if queued > 0: + logger.info("Queued LiveKit multitrack processing", count=queued) + + +@shared_task +@asynctask +async def reprocess_failed_livekit_recordings(): + """Reprocess LiveKit recordings that failed. + + Runs daily at 5 AM. Finds recordings with livekit prefix and error status. + """ + bucket_name = settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME + if not bucket_name: + return + + failed = await recordings_controller.get_multitrack_needing_reprocessing( + bucket_name + ) + livekit_failed = [r for r in failed if r.id.startswith("lk-")] + + for recording in livekit_failed: + if not recording.meeting_id: + logger.warning( + "Skipping reprocess — no meeting_id", + recording_id=recording.id, + ) + continue + + meeting = await meetings_controller.get_by_id(recording.meeting_id) + if not meeting: + continue + + logger.info( + "Reprocessing failed LiveKit recording", + recording_id=recording.id, + meeting_id=meeting.id, + ) + + process_livekit_multitrack.delay( + room_name=meeting.room_name, + meeting_id=meeting.id, + ) diff --git a/server/tests/test_livekit_backend.py b/server/tests/test_livekit_backend.py new file mode 100644 index 00000000..29efd19f --- /dev/null +++ b/server/tests/test_livekit_backend.py @@ -0,0 +1,408 @@ +""" +Tests for LiveKit backend: webhook verification, token generation, +display_name sanitization, and platform client behavior. +""" + +import re + +import pytest + +from reflector.livekit_api.webhooks import create_webhook_receiver, verify_webhook + +# ── Webhook verification ────────────────────────────────────── + + +class TestWebhookVerification: + def _make_receiver(self): + """Create a receiver with test credentials.""" + return create_webhook_receiver( + api_key="test_key", + api_secret="test_secret_that_is_long_enough_for_hmac", + ) + + def test_rejects_empty_auth_header(self): + receiver = self._make_receiver() + result = verify_webhook(receiver, b'{"event":"test"}', "") + assert result is None + + def test_rejects_garbage_auth_header(self): + receiver = self._make_receiver() + result = verify_webhook(receiver, b'{"event":"test"}', "not-a-jwt") + assert result is None + + def test_rejects_empty_body(self): + receiver = self._make_receiver() + result = verify_webhook(receiver, b"", "Bearer some.jwt.token") + assert result is None + + def test_handles_bytes_body(self): + receiver = self._make_receiver() + # Should not crash on bytes input + result = verify_webhook(receiver, b'{"event":"test"}', "invalid") + assert result is None + + def test_handles_string_body(self): + receiver = self._make_receiver() + result = verify_webhook(receiver, '{"event":"test"}', "invalid") + assert result is None + + def test_rejects_wrong_secret(self): + """Webhook signed with different secret should be rejected.""" + receiver = self._make_receiver() + # A JWT signed with a different secret + fake_jwt = "eyJhbGciOiJIUzI1NiJ9.eyJ0ZXN0IjoxfQ.wrong_signature" + result = verify_webhook(receiver, b"{}", fake_jwt) + assert result is None + + +# ── Token generation ────────────────────────────────────────── + + +class TestTokenGeneration: + """Test token generation using the LiveKit SDK directly (no client instantiation).""" + + def _generate_token( + self, room_name="room", identity="user", name=None, admin=False, ttl=86400 + ): + """Generate a token using the SDK directly, avoiding LiveKitAPI client session.""" + from datetime import timedelta + + from livekit.api import AccessToken, VideoGrants + + token = AccessToken( + api_key="test_key", api_secret="test_secret_that_is_long_enough_for_hmac" + ) + token.identity = identity + token.name = name or identity + token.ttl = timedelta(seconds=ttl) + token.with_grants( + VideoGrants( + room_join=True, + room=room_name, + can_publish=True, + can_subscribe=True, + room_admin=admin, + ) + ) + return token.to_jwt() + + def _decode_claims(self, token): + import base64 + import json + + payload = token.split(".")[1] + payload += "=" * (4 - len(payload) % 4) + return json.loads(base64.b64decode(payload)) + + def test_creates_valid_jwt(self): + token = self._generate_token( + room_name="test-room", identity="user123", name="Test User" + ) + assert isinstance(token, str) + assert len(token.split(".")) == 3 + + def test_token_includes_room_name(self): + token = self._generate_token(room_name="my-room-20260401", identity="alice") + claims = self._decode_claims(token) + assert claims.get("video", {}).get("room") == "my-room-20260401" + assert claims.get("sub") == "alice" + + def test_token_respects_admin_flag(self): + token = self._generate_token(identity="admin", admin=True) + claims = self._decode_claims(token) + assert claims["video"]["roomAdmin"] is True + + def test_token_non_admin_by_default(self): + token = self._generate_token(identity="user") + claims = self._decode_claims(token) + assert claims.get("video", {}).get("roomAdmin") in (None, False) + + def test_ttl_is_timedelta(self): + """Verify ttl as timedelta works (previous bug: int caused TypeError).""" + token = self._generate_token(ttl=3600) + assert isinstance(token, str) + + +# ── Display name sanitization ───────────────────────────────── + + +class TestDisplayNameSanitization: + """Test the sanitization logic from rooms.py join endpoint.""" + + def _sanitize(self, display_name: str) -> str: + """Replicate the sanitization from rooms_join_meeting.""" + safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", display_name.strip())[:40] + return safe_name + + def test_normal_name(self): + assert self._sanitize("Alice") == "Alice" + + def test_name_with_spaces(self): + assert self._sanitize("John Doe") == "John_Doe" + + def test_name_with_special_chars(self): + assert self._sanitize("user@email.com") == "user_email_com" + + def test_name_with_unicode(self): + result = self._sanitize("José García") + assert result == "Jos__Garc_a" + assert all( + c in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" + for c in result + ) + + def test_name_with_emoji(self): + result = self._sanitize("👋 Hello") + assert "_" in result # Emoji replaced with underscore + assert "Hello" in result + + def test_very_long_name(self): + long_name = "A" * 100 + result = self._sanitize(long_name) + assert len(result) == 40 + + def test_empty_name(self): + result = self._sanitize("") + assert result == "" + + def test_only_special_chars(self): + result = self._sanitize("!!!") + assert result == "___" + + def test_whitespace_stripped(self): + result = self._sanitize(" Alice ") + assert result == "Alice" + + def test_hyphens_preserved(self): + assert self._sanitize("first-last") == "first-last" + + def test_underscores_preserved(self): + assert self._sanitize("first_last") == "first_last" + + def test_html_injection(self): + result = self._sanitize("") + assert "<" not in result + assert ">" not in result + assert "'" not in result + + +# ── S3 egress configuration ─────────────────────────────────── + + +class TestS3EgressConfig: + """Test S3Upload construction using the SDK directly.""" + + def test_build_s3_upload_requires_all_fields(self): + # Missing fields should raise or produce invalid config + # The validation happens in our client wrapper, not the SDK + # Test the validation logic directly + s3_bucket = None + s3_access_key = "AKID" + s3_secret_key = "secret" + assert not all([s3_bucket, s3_access_key, s3_secret_key]) + + def test_s3_upload_with_credentials(self): + from livekit.api import S3Upload + + upload = S3Upload( + access_key="AKID", + secret="secret123", + bucket="test-bucket", + region="us-east-1", + force_path_style=True, + ) + assert upload.bucket == "test-bucket" + assert upload.force_path_style is True + + def test_s3_upload_with_endpoint(self): + from livekit.api import S3Upload + + upload = S3Upload( + access_key="AKID", + secret="secret", + bucket="bucket", + region="us-east-1", + force_path_style=True, + endpoint="http://garage:3900", + ) + assert upload.endpoint == "http://garage:3900" + + +# ── Platform detection ──────────────────────────────────────── + + +# ── Redis participant mapping ────────────────────────────── + + +class TestParticipantIdentityMapping: + """Test the identity → user_id Redis mapping pattern.""" + + def test_mapping_key_format(self): + room_name = "myroom-20260401172036" + mapping_key = f"livekit:participant_map:{room_name}" + assert mapping_key == "livekit:participant_map:myroom-20260401172036" + + def test_identity_with_uuid_suffix_is_unique(self): + import uuid + + name = "Juan" + id1 = f"{name}-{uuid.uuid4().hex[:6]}" + id2 = f"{name}-{uuid.uuid4().hex[:6]}" + assert id1 != id2 + assert id1.startswith("Juan-") + assert id2.startswith("Juan-") + + def test_strip_uuid_suffix_for_display(self): + """Pipeline strips UUID suffix for display name.""" + identity = "Juan-2bcea0" + display_name = identity.rsplit("-", 1)[0] if "-" in identity else identity + assert display_name == "Juan" + + def test_strip_uuid_preserves_hyphenated_names(self): + identity = "Mary-Jane-abc123" + display_name = identity.rsplit("-", 1)[0] if "-" in identity else identity + assert display_name == "Mary-Jane" + + def test_anon_identity_no_user_id(self): + """Anonymous participants should not have a user_id mapping.""" + identity = "anon-abc123" + # In the pipeline, anon identities don't get looked up + assert identity.startswith("anon-") + + @pytest.mark.asyncio + async def test_redis_hset_hgetall_roundtrip(self): + """Test the actual Redis operations used for participant mapping.""" + try: + from reflector.redis_cache import get_async_redis_client + + redis_client = await get_async_redis_client() + test_key = "livekit:participant_map:__test_room__" + + # Write + await redis_client.hset(test_key, "Juan-abc123", "user-id-1") + await redis_client.hset(test_key, "Alice-def456", "user-id-2") + + # Read + raw_map = await redis_client.hgetall(test_key) + decoded = { + k.decode() if isinstance(k, bytes) else k: v.decode() + if isinstance(v, bytes) + else v + for k, v in raw_map.items() + } + + assert decoded["Juan-abc123"] == "user-id-1" + assert decoded["Alice-def456"] == "user-id-2" + + # Cleanup + await redis_client.delete(test_key) + except Exception: + pytest.skip("Redis not available") + + +# ── Egress video cleanup safety ──────────────────────────────── + + +class TestEgressVideoCleanup: + """Ensure video cleanup logic NEVER deletes audio files.""" + + AUDIO_FILES = [ + "livekit/room-20260401/juan-abc123-2026-04-01T100000-TR_AMR3SWs74Divho.ogg", + "livekit/room-20260401/alice-def456-2026-04-01T100030-TR_AMirKjdAvLteAZ.ogg", + "livekit/room-20260401/bob-789abc-2026-04-01T100100-TR_AMyoSbM7tAQbYj.ogg", + ] + + VIDEO_FILES = [ + "livekit/room-20260401/juan-abc123-2026-04-01T100000-TR_VC679dgMQBdfhT.webm", + "livekit/room-20260401/alice-def456-2026-04-01T100030-TR_VCLsuRuxLp4eik.webm", + ] + + MANIFEST_FILES = [ + "livekit/room-20260401/EG_K5sipvfB5fTM.json", + "livekit/room-20260401/EG_nzwBsH9xzgoj.json", + ] + + def _should_delete(self, filename: str) -> bool: + """Replicate the deletion logic from _handle_egress_ended.""" + return filename.endswith(".webm") + + def test_audio_files_never_deleted(self): + """CRITICAL: Audio files must NEVER be marked for deletion.""" + for f in self.AUDIO_FILES: + assert not self._should_delete(f), f"Audio file would be deleted: {f}" + + def test_video_files_are_deleted(self): + for f in self.VIDEO_FILES: + assert self._should_delete(f), f"Video file NOT marked for deletion: {f}" + + def test_manifests_are_kept(self): + for f in self.MANIFEST_FILES: + assert not self._should_delete(f), f"Manifest would be deleted: {f}" + + def test_ogg_extension_never_matches_delete(self): + """Double-check: no .ogg file ever matches the deletion condition.""" + test_names = [ + "anything.ogg", + "livekit/room/track.ogg", + "video.ogg", # Even if someone names it "video.ogg" + ".ogg", + "TR_VC_fake_video.ogg", # Video-like track ID but .ogg extension + ] + for f in test_names: + assert not self._should_delete(f), f".ogg file would be deleted: {f}" + + def test_webm_always_matches_delete(self): + test_names = [ + "anything.webm", + "livekit/room/track.webm", + "audio.webm", # Even if someone names it "audio.webm" + ".webm", + ] + for f in test_names: + assert self._should_delete(f), f".webm file NOT marked for deletion: {f}" + + def test_unknown_extensions_are_kept(self): + """Unknown file types should NOT be deleted (safe by default).""" + test_names = [ + "file.mp4", + "file.wav", + "file.mp3", + "file.txt", + "file", + "", + ] + for f in test_names: + assert not self._should_delete( + f + ), f"Unknown file type would be deleted: {f}" + + +# ── Platform detection ──────────────────────────────────────── + + +class TestSourcePlatformDetection: + """Test the recording ID prefix-based platform detection from transcript_process.py.""" + + def test_livekit_prefix(self): + recording_id = "lk-livekit-20260401234423" + platform = "livekit" if recording_id.startswith("lk-") else "daily" + assert platform == "livekit" + + def test_daily_no_prefix(self): + recording_id = "08fa0b24-9220-44c5-846c-3f116cf8e738" + platform = "livekit" if recording_id.startswith("lk-") else "daily" + assert platform == "daily" + + def test_none_recording_id(self): + recording_id = None + platform = ( + "livekit" if recording_id and recording_id.startswith("lk-") else "daily" + ) + assert platform == "daily" + + def test_empty_recording_id(self): + recording_id = "" + platform = ( + "livekit" if recording_id and recording_id.startswith("lk-") else "daily" + ) + assert platform == "daily" diff --git a/server/tests/test_livekit_track_processing.py b/server/tests/test_livekit_track_processing.py new file mode 100644 index 00000000..4e2e98b7 --- /dev/null +++ b/server/tests/test_livekit_track_processing.py @@ -0,0 +1,393 @@ +""" +Tests for LiveKit track processing: filepath parsing, offset calculation, +and pad_track padding_seconds behavior. +""" + +from datetime import datetime, timezone +from fractions import Fraction + +import av +import pytest + +from reflector.utils.livekit import ( + LiveKitTrackFile, + calculate_track_offsets, + extract_livekit_base_room_name, + filter_audio_tracks, + parse_livekit_track_filepath, +) + +# ── Filepath parsing ────────────────────────────────────────── + + +class TestParseLiveKitTrackFilepath: + def test_parses_ogg_audio_track(self): + result = parse_livekit_track_filepath( + "livekit/myroom-20260401172036/juan-4b82ed-2026-04-01T195758-TR_AMR3SWs74Divho.ogg" + ) + assert result.room_name == "myroom-20260401172036" + assert result.participant_identity == "juan-4b82ed" + assert result.track_id == "TR_AMR3SWs74Divho" + assert result.timestamp == datetime(2026, 4, 1, 19, 57, 58, tzinfo=timezone.utc) + + def test_parses_different_identities(self): + r1 = parse_livekit_track_filepath( + "livekit/room-20260401/alice-a1b2c3-2026-04-01T100000-TR_abc123.ogg" + ) + r2 = parse_livekit_track_filepath( + "livekit/room-20260401/bob_smith-d4e5f6-2026-04-01T100030-TR_def456.ogg" + ) + assert r1.participant_identity == "alice-a1b2c3" + assert r2.participant_identity == "bob_smith-d4e5f6" + + def test_rejects_json_manifest(self): + with pytest.raises(ValueError, match="doesn't match expected format"): + parse_livekit_track_filepath("livekit/myroom-20260401/EG_K5sipvfB5fTM.json") + + def test_rejects_webm_video(self): + # webm files match the pattern but are filtered by filter_audio_tracks + result = parse_livekit_track_filepath( + "livekit/myroom-20260401/juan-4b82ed-2026-04-01T195727-TR_VC679dgMQBdfhT.webm" + ) + # webm parses successfully (TR_ prefix matches video tracks too) + assert result.track_id == "TR_VC679dgMQBdfhT" + + def test_rejects_invalid_path(self): + with pytest.raises(ValueError): + parse_livekit_track_filepath("not/a/valid/path.ogg") + + def test_rejects_missing_track_id(self): + with pytest.raises(ValueError): + parse_livekit_track_filepath("livekit/room/user-2026-04-01T100000.ogg") + + def test_parses_timestamp_correctly(self): + result = parse_livekit_track_filepath( + "livekit/room-20260401/user-abc123-2026-12-25T235959-TR_test.ogg" + ) + assert result.timestamp == datetime( + 2026, 12, 25, 23, 59, 59, tzinfo=timezone.utc + ) + + +# ── Audio track filtering ───────────────────────────────────── + + +class TestFilterAudioTracks: + def test_filters_to_ogg_only(self): + keys = [ + "livekit/room/EG_abc.json", + "livekit/room/user-abc-2026-04-01T100000-TR_audio.ogg", + "livekit/room/user-abc-2026-04-01T100000-TR_video.webm", + "livekit/room/EG_def.json", + "livekit/room/user2-def-2026-04-01T100030-TR_audio2.ogg", + ] + result = filter_audio_tracks(keys) + assert len(result) == 2 + assert all(k.endswith(".ogg") for k in result) + + def test_empty_input(self): + assert filter_audio_tracks([]) == [] + + def test_no_audio_tracks(self): + keys = ["livekit/room/EG_abc.json", "livekit/room/user-TR_v.webm"] + assert filter_audio_tracks(keys) == [] + + +# ── Offset calculation ───────────────────────────────────────── + + +class TestCalculateTrackOffsets: + def test_single_track_zero_offset(self): + tracks = [ + LiveKitTrackFile( + s3_key="k1", + room_name="r", + participant_identity="alice", + timestamp=datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc), + track_id="TR_1", + ) + ] + offsets = calculate_track_offsets(tracks) + assert len(offsets) == 1 + assert offsets[0][1] == 0.0 + + def test_two_tracks_correct_offset(self): + tracks = [ + LiveKitTrackFile( + s3_key="k1", + room_name="r", + participant_identity="alice", + timestamp=datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc), + track_id="TR_1", + ), + LiveKitTrackFile( + s3_key="k2", + room_name="r", + participant_identity="bob", + timestamp=datetime(2026, 4, 1, 10, 1, 10, tzinfo=timezone.utc), + track_id="TR_2", + ), + ] + offsets = calculate_track_offsets(tracks) + assert offsets[0][1] == 0.0 # alice (earliest) + assert offsets[1][1] == 70.0 # bob (70 seconds later) + + def test_three_tracks_earliest_is_zero(self): + tracks = [ + LiveKitTrackFile( + s3_key="k2", + room_name="r", + participant_identity="bob", + timestamp=datetime(2026, 4, 1, 10, 0, 30, tzinfo=timezone.utc), + track_id="TR_2", + ), + LiveKitTrackFile( + s3_key="k1", + room_name="r", + participant_identity="alice", + timestamp=datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc), + track_id="TR_1", + ), + LiveKitTrackFile( + s3_key="k3", + room_name="r", + participant_identity="charlie", + timestamp=datetime(2026, 4, 1, 10, 1, 0, tzinfo=timezone.utc), + track_id="TR_3", + ), + ] + offsets = calculate_track_offsets(tracks) + offset_map = {t.participant_identity: o for t, o in offsets} + assert offset_map["alice"] == 0.0 + assert offset_map["bob"] == 30.0 + assert offset_map["charlie"] == 60.0 + + def test_empty_tracks(self): + assert calculate_track_offsets([]) == [] + + def test_simultaneous_tracks_zero_offsets(self): + ts = datetime(2026, 4, 1, 10, 0, 0, tzinfo=timezone.utc) + tracks = [ + LiveKitTrackFile( + s3_key="k1", + room_name="r", + participant_identity="a", + timestamp=ts, + track_id="TR_1", + ), + LiveKitTrackFile( + s3_key="k2", + room_name="r", + participant_identity="b", + timestamp=ts, + track_id="TR_2", + ), + ] + offsets = calculate_track_offsets(tracks) + assert all(o == 0.0 for _, o in offsets) + + +# ── Room name extraction ─────────────────────────────────────── + + +class TestExtractLiveKitBaseRoomName: + def test_strips_timestamp_suffix(self): + assert extract_livekit_base_room_name("myroom-20260401172036") == "myroom" + + def test_preserves_hyphenated_name(self): + assert ( + extract_livekit_base_room_name("my-room-name-20260401172036") + == "my-room-name" + ) + + def test_single_segment(self): + assert extract_livekit_base_room_name("room-20260401") == "room" + + +# ── pad_track padding_seconds behavior ───────────────────────── + + +class TestPadTrackPaddingSeconds: + """Test that pad_track correctly uses pre-calculated padding_seconds + for LiveKit (skipping container metadata) vs extracting from container + for Daily (when padding_seconds is None). + """ + + def _make_test_ogg(self, path: str, duration_seconds: float = 5.0): + """Create a minimal OGG/Opus file for testing.""" + with av.open(path, "w", format="ogg") as out: + stream = out.add_stream("libopus", rate=48000) + stream.bit_rate = 64000 + samples_per_frame = 960 # Opus standard + total_samples = int(duration_seconds * 48000) + pts = 0 + while pts < total_samples: + frame = av.AudioFrame( + format="s16", layout="stereo", samples=samples_per_frame + ) + # Fill with silence (zeros) + frame.planes[0].update(bytes(samples_per_frame * 2 * 2)) # s16 * stereo + frame.sample_rate = 48000 + frame.pts = pts + frame.time_base = Fraction(1, 48000) + for packet in stream.encode(frame): + out.mux(packet) + pts += samples_per_frame + for packet in stream.encode(None): + out.mux(packet) + + def test_ogg_has_zero_start_time(self, tmp_path): + """Verify that OGG files (like LiveKit produces) have start_time=0, + confirming why pre-calculated padding is needed.""" + ogg_path = str(tmp_path / "test.ogg") + self._make_test_ogg(ogg_path) + + with av.open(ogg_path) as container: + from reflector.utils.audio_padding import ( + extract_stream_start_time_from_container, + ) + + start_time = extract_stream_start_time_from_container(container, 0) + + assert start_time <= 0.0, ( + "OGG files should have start_time<=0 (no usable offset), confirming " + f"LiveKit tracks need pre-calculated padding_seconds. Got: {start_time}" + ) + + def test_precalculated_padding_skips_metadata_extraction(self, tmp_path): + """When padding_seconds is set, pad_track should use it directly + and NOT call extract_stream_start_time_from_container.""" + from reflector.hatchet.workflows.track_processing import TrackInput + + input_data = TrackInput( + track_index=0, + s3_key="livekit/room/user-abc-2026-04-01T100000-TR_audio.ogg", + bucket_name="test-bucket", + transcript_id="test-transcript", + source_platform="livekit", + padding_seconds=70.0, + ) + + assert input_data.padding_seconds == 70.0 + # The pad_track function checks: if input.padding_seconds is not None → use it + # This means extract_stream_start_time_from_container is never called for LiveKit + + def test_none_padding_falls_back_to_metadata(self, tmp_path): + """When padding_seconds is None (Daily), pad_track should extract + start_time from container metadata.""" + from reflector.hatchet.workflows.track_processing import TrackInput + + input_data = TrackInput( + track_index=0, + s3_key="daily/room/track.webm", + bucket_name="test-bucket", + transcript_id="test-transcript", + source_platform="daily", + padding_seconds=None, + ) + + assert input_data.padding_seconds is None + # pad_track will call extract_stream_start_time_from_container for this case + + def test_zero_padding_returns_original_key(self): + """When padding_seconds=0.0, pad_track should return the original S3 key + without applying any padding (same as start_time=0 from metadata).""" + from reflector.hatchet.workflows.track_processing import TrackInput + + input_data = TrackInput( + track_index=0, + s3_key="livekit/room/earliest-track.ogg", + bucket_name="test-bucket", + transcript_id="test-transcript", + source_platform="livekit", + padding_seconds=0.0, + ) + + # padding_seconds=0.0 → start_time_seconds=0.0 → "no padding needed" branch + assert input_data.padding_seconds == 0.0 + + +# ── Pipeline offset calculation (process_tracks logic) ───────── + + +class TestProcessTracksOffsetCalculation: + """Test the offset calculation logic used in process_tracks + for LiveKit source_platform.""" + + def test_livekit_offsets_from_timestamps(self): + """Simulate the offset calculation done in process_tracks.""" + tracks = [ + { + "s3_key": "track1.ogg", + "participant_identity": "admin-0129c3", + "timestamp": "2026-04-01T23:44:50+00:00", + }, + { + "s3_key": "track2.ogg", + "participant_identity": "juan-5a5b41", + "timestamp": "2026-04-01T23:46:00+00:00", + }, + ] + + # Replicate the logic from process_tracks + timestamps = [] + for i, track in enumerate(tracks): + ts_str = track.get("timestamp") + if ts_str: + ts = datetime.fromisoformat(ts_str) + timestamps.append((i, ts)) + + earliest = min(ts for _, ts in timestamps) + track_padding = {} + for i, ts in timestamps: + track_padding[i] = (ts - earliest).total_seconds() + + assert track_padding[0] == 0.0 # admin (earliest) + assert track_padding[1] == 70.0 # juan (70s later) + + def test_daily_tracks_get_no_precalculated_padding(self): + """Daily tracks should NOT get padding_seconds (use container metadata).""" + tracks = [ + {"s3_key": "daily-track1.webm"}, + {"s3_key": "daily-track2.webm"}, + ] + + # Daily tracks don't have "timestamp" field + track_padding = {} + source_platform = "daily" + + if source_platform == "livekit": + # This block should NOT execute for daily + pass + + # Daily tracks get no pre-calculated padding + assert track_padding == {} + for i, _ in enumerate(tracks): + assert track_padding.get(i) is None + + def test_livekit_missing_timestamp_graceful(self): + """If a LiveKit track is missing timestamp, it should be skipped.""" + tracks = [ + { + "s3_key": "track1.ogg", + "participant_identity": "alice", + "timestamp": "2026-04-01T10:00:00+00:00", + }, + {"s3_key": "track2.ogg", "participant_identity": "bob"}, # no timestamp + ] + + timestamps = [] + for i, track in enumerate(tracks): + ts_str = track.get("timestamp") + if ts_str: + try: + ts = datetime.fromisoformat(ts_str) + timestamps.append((i, ts)) + except (ValueError, TypeError): + timestamps.append((i, None)) + else: + timestamps.append((i, None)) + + valid = [(i, ts) for i, ts in timestamps if ts is not None] + assert len(valid) == 1 # only alice has a timestamp + assert valid[0][0] == 0 # track index 0 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..e6d419fa --- /dev/null +++ b/www/app/[roomName]/components/LiveKitRoom.tsx @@ -0,0 +1,277 @@ +"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, + PreJoin, + type LocalUserChoices, +} from "@livekit/components-react"; +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); + const [userChoices, setUserChoices] = useState(null); + + // ── 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"); + + // ── PreJoin defaults (persisted to localStorage for page refresh) ── + const STORAGE_KEY = `livekit-username-${roomName}`; + const defaultUsername = (() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) return saved; + } + if (auth.status === "authenticated" || auth.status === "refreshing") { + return auth.user.email?.split("@")[0] || auth.user.id?.slice(0, 12) || ""; + } + return ""; + })(); + const isJoining = !!userChoices && !joinedMeeting && !connectionError; + + // ── Join meeting via backend API after PreJoin submit ───── + useEffect(() => { + if ( + authLastUserId === undefined || + !userChoices || + !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 }, + query: { display_name: userChoices!.username || undefined }, + }, + }); + 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, userChoices]); + + const handleDisconnected = useCallback(() => { + router.push("/browse"); + }, [router]); + + const handlePreJoinSubmit = useCallback( + (choices: LocalUserChoices) => { + // Persist username for page refresh + if (choices.username) { + localStorage.setItem(STORAGE_KEY, choices.username); + } + setUserChoices(choices); + }, + [STORAGE_KEY], + ); + + // ── PreJoin screen (name + device selection) ────────────── + if (!userChoices) { + return ( + + + + ); + } + + // ── Loading / error states ──────────────────────────────── + if (isJoining) { + return ( +
+ +
+ ); + } + + 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..dd41259f 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 */ @@ -3233,7 +3259,9 @@ export interface operations { }; v1_rooms_join_meeting: { parameters: { - query?: never; + query?: { + display_name?: string | null; + }; header?: never; path: { room_name: string; @@ -4504,6 +4532,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: {}