mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-23 13:45:18 +00:00
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
This commit is contained in:
committed by
GitHub
parent
b570d202dc
commit
bc8338fa4f
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,6 +3,8 @@ server/.env
|
|||||||
server/.env.production
|
server/.env.production
|
||||||
.env
|
.env
|
||||||
Caddyfile
|
Caddyfile
|
||||||
|
livekit.yaml
|
||||||
|
egress.yaml
|
||||||
.env.hatchet
|
.env.hatchet
|
||||||
server/exportdanswer
|
server/exportdanswer
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
image: monadicalsas/reflector-backend:latest
|
image: monadicalsas/reflector-backend:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:1250:1250"
|
- "${BIND_HOST:-127.0.0.1}:1250:1250"
|
||||||
- "40000-40100:40000-40100/udp"
|
- "40000-40100:40000-40100/udp"
|
||||||
env_file:
|
env_file:
|
||||||
- ./server/.env
|
- ./server/.env
|
||||||
@@ -116,7 +116,7 @@ services:
|
|||||||
image: monadicalsas/reflector-frontend:latest
|
image: monadicalsas/reflector-frontend:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "${BIND_HOST:-127.0.0.1}:3000:3000"
|
||||||
env_file:
|
env_file:
|
||||||
- ./www/.env
|
- ./www/.env
|
||||||
environment:
|
environment:
|
||||||
@@ -339,7 +339,7 @@ services:
|
|||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
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"
|
- "127.0.0.1:7078:7077"
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env.hatchet
|
- ./.env.hatchet
|
||||||
@@ -366,7 +366,7 @@ services:
|
|||||||
context: ./server
|
context: ./server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: monadicalsas/reflector-backend:latest
|
image: monadicalsas/reflector-backend:latest
|
||||||
profiles: [dailyco]
|
profiles: [dailyco, livekit]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./server/.env
|
- ./server/.env
|
||||||
@@ -406,6 +406,40 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- server_data:/app/data
|
- server_data:/app/data
|
||||||
|
|
||||||
|
# ===========================================================
|
||||||
|
# LiveKit — self-hosted open-source video platform
|
||||||
|
# Activated via --profile livekit (auto-detected from LIVEKIT_API_KEY in server/.env)
|
||||||
|
# ===========================================================
|
||||||
|
|
||||||
|
livekit-server:
|
||||||
|
image: livekit/livekit-server:v1.10.1
|
||||||
|
profiles: [livekit]
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "7880:7880" # Signaling (HTTP/WS)
|
||||||
|
- "7881:7881" # WebRTC over TCP
|
||||||
|
- "44200-44300:44200-44300/udp" # WebRTC ICE (avoids macOS ephemeral 49152-65535 and Reflector 40000-40100)
|
||||||
|
volumes:
|
||||||
|
- ./livekit.yaml:/etc/livekit.yaml:ro
|
||||||
|
command: ["--config", "/etc/livekit.yaml"]
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
livekit-egress:
|
||||||
|
image: livekit/egress:v1.12.0
|
||||||
|
profiles: [livekit]
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
EGRESS_CONFIG_FILE: /etc/egress.yaml
|
||||||
|
volumes:
|
||||||
|
- ./egress.yaml:/etc/egress.yaml:ro
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
livekit-server:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|||||||
97
docsv2/firewall-ports.md
Normal file
97
docsv2/firewall-ports.md
Normal file
@@ -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 <DROPLET_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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`.
|
||||||
297
docsv2/livekit-setup.md
Normal file
297
docsv2/livekit-setup.md
Normal file
@@ -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://<server-ip>:7880`. With `--domain`, set to `wss://<domain>:7880`. | `wss://reflector.example.com:7880` |
|
||||||
|
| `LIVEKIT_WEBHOOK_SECRET` | Webhook verification secret. Defaults to `LIVEKIT_API_SECRET` if not set. Only needed if you want a separate secret for webhooks. | (same as API secret) |
|
||||||
|
| `DEFAULT_VIDEO_PLATFORM` | Default platform for new rooms | `livekit` |
|
||||||
|
|
||||||
|
### Storage (for Track Egress)
|
||||||
|
|
||||||
|
Track Egress writes per-participant audio files to S3. If not configured, falls back to the transcript storage credentials.
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `LIVEKIT_STORAGE_AWS_BUCKET_NAME` | S3 bucket for egress output | `reflector-bucket` |
|
||||||
|
| `LIVEKIT_STORAGE_AWS_REGION` | S3 region | `us-east-1` |
|
||||||
|
| `LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID` | S3 access key | `GK...` |
|
||||||
|
| `LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY` | S3 secret key | `...` |
|
||||||
|
| `LIVEKIT_STORAGE_AWS_ENDPOINT_URL` | S3 endpoint (for Garage/MinIO) | `http://garage:3900` |
|
||||||
|
|
||||||
|
## Docker Compose Services
|
||||||
|
|
||||||
|
Two services are added under the `livekit` profile in `docker-compose.selfhosted.yml`:
|
||||||
|
|
||||||
|
### livekit-server
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Image | `livekit/livekit-server:v1.10.1` |
|
||||||
|
| Ports | 7880 (signaling), 7881 (TCP RTC), 44200-44300/udp (ICE) |
|
||||||
|
| Config | `./livekit.yaml` mounted at `/etc/livekit.yaml` |
|
||||||
|
| Depends on | Redis |
|
||||||
|
|
||||||
|
### livekit-egress
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Image | `livekit/egress:v1.10.1` |
|
||||||
|
| Config | `./egress.yaml` mounted at `/etc/egress.yaml` |
|
||||||
|
| Depends on | Redis, livekit-server |
|
||||||
|
|
||||||
|
No `--cap-add=SYS_ADMIN` is needed because Track Egress does not use Chrome (that's only for Room Composite video recording, which we don't use).
|
||||||
|
|
||||||
|
## Port Ranges
|
||||||
|
|
||||||
|
| Range | Protocol | Service | Notes |
|
||||||
|
|-------|----------|---------|-------|
|
||||||
|
| 7880 | TCP | LiveKit signaling | WebSocket connections from browsers (direct, no Caddy) |
|
||||||
|
| 7881 | TCP | LiveKit RTC over TCP | Fallback when UDP is blocked |
|
||||||
|
| 44200-44300 | UDP | LiveKit ICE | WebRTC media. Avoids collision with Reflector WebRTC (40000-40100) and macOS ephemeral ports (49152-65535) |
|
||||||
|
|
||||||
|
### TLS / Caddy Integration
|
||||||
|
|
||||||
|
When `--caddy` is enabled (HTTPS), the setup script automatically:
|
||||||
|
|
||||||
|
1. Adds a `/lk-ws` reverse proxy route to the Caddyfile that proxies `wss://domain/lk-ws` → `ws://livekit-server:7880`
|
||||||
|
2. Sets `LIVEKIT_PUBLIC_URL` to `wss://<domain>/lk-ws` (or `wss://<ip>/lk-ws`)
|
||||||
|
|
||||||
|
This avoids mixed-content blocking (browsers reject `ws://` connections on `https://` pages). Caddy handles TLS termination; LiveKit server itself runs plain WebSocket internally.
|
||||||
|
|
||||||
|
Without `--caddy`, browsers connect directly to LiveKit on port 7880 via `ws://`.
|
||||||
|
|
||||||
|
### 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 `<VideoConference>` UI. It includes:
|
||||||
|
|
||||||
|
- Recording consent dialog (same as Daily/Whereby)
|
||||||
|
- Email transcript button (feature-gated)
|
||||||
|
- Extensible overlay buttons for custom actions
|
||||||
|
|
||||||
|
When a user joins a LiveKit room, the backend generates a JWT access token and returns it in the `room_url` query parameter. The frontend parses this and passes it to the LiveKit React SDK.
|
||||||
|
|
||||||
|
## Separate Server Deployment
|
||||||
|
|
||||||
|
For larger deployments (15+ participants, multiple simultaneous rooms), LiveKit can run on a dedicated server:
|
||||||
|
|
||||||
|
1. Run `livekit-server` and `livekit-egress` on a separate machine
|
||||||
|
2. Point `LIVEKIT_URL` to the remote LiveKit server (e.g., `ws://livekit-host:7880`)
|
||||||
|
3. Set `LIVEKIT_PUBLIC_URL` to the public-facing URL (e.g., `wss://livekit.example.com`)
|
||||||
|
4. Configure the remote LiveKit's `webhook.urls` to point back to the Reflector server
|
||||||
|
5. Both need access to the same Redis (or configure LiveKit's own Redis)
|
||||||
|
6. Both need access to the same S3 storage
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### LiveKit server not starting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker compose -f docker-compose.selfhosted.yml logs livekit-server --tail 30
|
||||||
|
|
||||||
|
# Verify config
|
||||||
|
cat livekit.yaml
|
||||||
|
|
||||||
|
# Common issues:
|
||||||
|
# - Redis not reachable (check redis service is running)
|
||||||
|
# - Port 7880 already in use
|
||||||
|
# - Invalid API key format in livekit.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Participants can't connect
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check that LIVEKIT_PUBLIC_URL is accessible from the browser
|
||||||
|
# It must be the URL the browser can reach, not the Docker-internal URL
|
||||||
|
|
||||||
|
# Check firewall allows ports 7880, 7881, and 44200-44300/udp
|
||||||
|
sudo ufw status # or iptables -L
|
||||||
|
|
||||||
|
# Verify the access token is being generated
|
||||||
|
docker compose -f docker-compose.selfhosted.yml logs server | grep livekit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Track Egress not writing files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check egress logs
|
||||||
|
docker compose -f docker-compose.selfhosted.yml logs livekit-egress --tail 30
|
||||||
|
|
||||||
|
# Verify S3 credentials
|
||||||
|
# Egress receives S3 config per-request from the server, so check server/.env:
|
||||||
|
grep LIVEKIT_STORAGE server/.env
|
||||||
|
```
|
||||||
73
docsv2/migrate-daily-to-livekit.md
Normal file
73
docsv2/migrate-daily-to-livekit.md
Normal file
@@ -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.
|
||||||
@@ -170,6 +170,8 @@ These start regardless of which flags you pass:
|
|||||||
| `ollama-cpu` | `ollama-cpu` | Local Ollama LLM on CPU |
|
| `ollama-cpu` | `ollama-cpu` | Local Ollama LLM on CPU |
|
||||||
| `garage` | `garage` | Local S3-compatible object storage |
|
| `garage` | `garage` | Local S3-compatible object storage |
|
||||||
| `caddy` | `caddy` | Reverse proxy with SSL |
|
| `caddy` | `caddy` | Reverse proxy with SSL |
|
||||||
|
| `dailyco` | `hatchet-worker-cpu` | Hatchet workflow workers for Daily.co multitrack processing |
|
||||||
|
| `livekit` | `livekit-server`, `livekit-egress` | Self-hosted video platform + per-participant audio recording |
|
||||||
|
|
||||||
### The "transcription" Alias
|
### The "transcription" Alias
|
||||||
|
|
||||||
@@ -206,11 +208,17 @@ Both the `gpu` and `cpu` services define a Docker network alias of `transcriptio
|
|||||||
│ :8000 │ └─────────┘ └─────────┘
|
│ :8000 │ └─────────┘ └─────────┘
|
||||||
└───────────┘
|
└───────────┘
|
||||||
│
|
│
|
||||||
┌─────┴─────┐ ┌─────────┐
|
┌─────┴─────┐ ┌─────────┐ ┌──────────────┐
|
||||||
│ ollama │ │ garage │
|
│ ollama │ │ garage │ │livekit-server│
|
||||||
│(optional) │ │(optional│
|
│(optional) │ │(optional│ │ (optional) │
|
||||||
│ :11435 │ │ S3) │
|
│ :11435 │ │ S3) │ │ :7880 │
|
||||||
└───────────┘ └─────────┘
|
└───────────┘ └─────────┘ └──────┬───────┘
|
||||||
|
│
|
||||||
|
┌──────┴───────┐
|
||||||
|
│livekit-egress│
|
||||||
|
│ (Track Egress│
|
||||||
|
│ to S3) │
|
||||||
|
└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### How Services Interact
|
### How Services Interact
|
||||||
@@ -320,7 +328,9 @@ You can point your own reverse proxy (nginx, Traefik, etc.) at these ports.
|
|||||||
|
|
||||||
### WebRTC and UDP
|
### WebRTC and UDP
|
||||||
|
|
||||||
The server exposes UDP ports 50000-50100 for WebRTC ICE candidates. The `WEBRTC_HOST` variable tells the server which IP to advertise in ICE candidates — this must be the server's actual IP address (not a domain), because WebRTC uses UDP which doesn't go through the HTTP reverse proxy.
|
The server exposes UDP ports 40000-40100 for Reflector's own WebRTC ICE candidates. When LiveKit is enabled, it additionally uses ports 44200-44300/udp for its WebRTC ICE candidates. The `WEBRTC_HOST` variable tells the server which IP to advertise in ICE candidates — this must be the server's actual IP address (not a domain), because WebRTC uses UDP which doesn't go through the HTTP reverse proxy.
|
||||||
|
|
||||||
|
Port ranges are chosen to avoid collision with macOS ephemeral ports (49152-65535).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -426,7 +436,10 @@ All services communicate over Docker's default bridge network. Only specific por
|
|||||||
| 3903 | Garage | `0.0.0.0:3903` | Garage admin API |
|
| 3903 | Garage | `0.0.0.0:3903` | Garage admin API |
|
||||||
| 8000 | GPU/CPU | `127.0.0.1:8000` | ML model API (localhost only) |
|
| 8000 | GPU/CPU | `127.0.0.1:8000` | ML model API (localhost only) |
|
||||||
| 11435 | Ollama | `127.0.0.1:11435` | Ollama API (localhost only) |
|
| 11435 | Ollama | `127.0.0.1:11435` | Ollama API (localhost only) |
|
||||||
| 50000-50100/udp | Server | `0.0.0.0:50000-50100` | WebRTC ICE candidates |
|
| 40000-40100/udp | Server | `0.0.0.0:40000-40100` | Reflector WebRTC ICE candidates |
|
||||||
|
| 7880 | LiveKit | `0.0.0.0:7880` | LiveKit signaling (WS) |
|
||||||
|
| 7881 | LiveKit | `0.0.0.0:7881` | LiveKit RTC over TCP |
|
||||||
|
| 44200-44300/udp | LiveKit | `0.0.0.0:44200-44300` | LiveKit WebRTC ICE candidates |
|
||||||
|
|
||||||
Services bound to `127.0.0.1` are only accessible from the host itself (not from the network). Caddy is the only service exposed to the internet on standard HTTP/HTTPS ports.
|
Services bound to `127.0.0.1` are only accessible from the host itself (not from the network). Caddy is the only service exposed to the internet on standard HTTP/HTTPS ports.
|
||||||
|
|
||||||
@@ -443,6 +456,8 @@ Inside the Docker network, services reach each other by their compose service na
|
|||||||
| `transcription` | GPU or CPU container (network alias) |
|
| `transcription` | GPU or CPU container (network alias) |
|
||||||
| `ollama` / `ollama-cpu` | Ollama container |
|
| `ollama` / `ollama-cpu` | Ollama container |
|
||||||
| `garage` | Garage S3 container |
|
| `garage` | Garage S3 container |
|
||||||
|
| `livekit-server` | LiveKit SFU server |
|
||||||
|
| `livekit-egress` | LiveKit Track Egress service |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ Browse all available models at https://ollama.com/library.
|
|||||||
|
|
||||||
| Flag | What it does |
|
| Flag | What it does |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
|
| `--livekit` | Enables LiveKit self-hosted video platform. Generates API credentials, starts `livekit-server` + `livekit-egress`. See [LiveKit Setup](livekit-setup.md). |
|
||||||
| `--garage` | Starts Garage (local S3-compatible storage). Auto-configures bucket, keys, and env vars. |
|
| `--garage` | Starts Garage (local S3-compatible storage). Auto-configures bucket, keys, and env vars. |
|
||||||
| `--caddy` | Starts Caddy reverse proxy on ports 80/443 with self-signed cert. |
|
| `--caddy` | Starts Caddy reverse proxy on ports 80/443 with self-signed cert. |
|
||||||
| `--domain DOMAIN` | Use a real domain with Let's Encrypt auto-HTTPS (implies `--caddy`). Requires DNS A record pointing to this server and ports 80/443 open. |
|
| `--domain DOMAIN` | Use a real domain with Let's Encrypt auto-HTTPS (implies `--caddy`). Requires DNS A record pointing to this server and ports 80/443 open. |
|
||||||
@@ -154,6 +155,20 @@ Without `--garage`, you **must** provide S3-compatible credentials (the script w
|
|||||||
|
|
||||||
Without `--caddy` or `--domain`, no ports are exposed. Point your own reverse proxy at `web:3000` (frontend) and `server:1250` (API).
|
Without `--caddy` or `--domain`, no ports are exposed. Point your own reverse proxy at `web:3000` (frontend) and `server:1250` (API).
|
||||||
|
|
||||||
|
## Video Platform (LiveKit)
|
||||||
|
|
||||||
|
For self-hosted video rooms with per-participant audio recording, add `--livekit` to your setup command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates LiveKit API credentials, creates config files (`livekit.yaml`, `egress.yaml`), and starts `livekit-server` (WebRTC SFU) + `livekit-egress` (per-participant audio recording to S3). LiveKit reuses the same Redis and S3 storage as the rest of the stack.
|
||||||
|
|
||||||
|
New rooms default to LiveKit when `DEFAULT_VIDEO_PLATFORM=livekit` is set (done automatically by the setup script). Existing Daily.co and Whereby rooms continue to work. On re-runs, the script detects the existing `LIVEKIT_API_KEY` in `server/.env` automatically.
|
||||||
|
|
||||||
|
> For detailed configuration, environment variables, ports, and troubleshooting, see [LiveKit Setup](livekit-setup.md).
|
||||||
|
|
||||||
**Using a domain (recommended for production):** Point a DNS A record at your server's IP, then pass `--domain your.domain.com`. Caddy will automatically obtain and renew a Let's Encrypt certificate. Ports 80 and 443 must be open.
|
**Using a domain (recommended for production):** Point a DNS A record at your server's IP, then pass `--domain your.domain.com`. Caddy will automatically obtain and renew a Let's Encrypt certificate. Ports 80 and 443 must be open.
|
||||||
|
|
||||||
**Without a domain:** `--caddy` alone uses a self-signed certificate. Browsers will show a security warning that must be accepted.
|
**Without a domain:** `--caddy` alone uses a self-signed certificate. Browsers will show a security warning that must be accepted.
|
||||||
|
|||||||
26
egress.yaml.example
Normal file
26
egress.yaml.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# LiveKit Egress configuration
|
||||||
|
# Generated by setup-selfhosted.sh — do not edit manually.
|
||||||
|
# See: https://docs.livekit.io/self-hosting/egress/
|
||||||
|
|
||||||
|
api_key: __LIVEKIT_API_KEY__
|
||||||
|
api_secret: __LIVEKIT_API_SECRET__
|
||||||
|
ws_url: ws://livekit-server:7880
|
||||||
|
redis:
|
||||||
|
address: redis:6379
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
health_port: 7082
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log_level: info
|
||||||
|
|
||||||
|
# CPU cost limits (Track Egress only — no composite video)
|
||||||
|
# Track Egress costs 1.0 CPU unit per track; hundreds can run on one instance.
|
||||||
|
# Default max_cpu_utilization is 0.8 (80% of available cores).
|
||||||
|
|
||||||
|
# Session limits
|
||||||
|
session_limits:
|
||||||
|
file_output_max_duration: 4h # Max 4 hours per recording
|
||||||
|
|
||||||
|
# S3 storage is configured per-request via the API (not here).
|
||||||
|
# The server passes S3 credentials when starting each Track Egress.
|
||||||
34
livekit.yaml.example
Normal file
34
livekit.yaml.example
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# LiveKit server configuration
|
||||||
|
# Generated by setup-selfhosted.sh — do not edit manually.
|
||||||
|
# See: https://docs.livekit.io/self-hosting/deployment/
|
||||||
|
|
||||||
|
port: 7880
|
||||||
|
rtc:
|
||||||
|
tcp_port: 7881
|
||||||
|
port_range_start: 44200
|
||||||
|
port_range_end: 44300
|
||||||
|
# use_external_ip: true # Uncomment for production with public IP
|
||||||
|
|
||||||
|
redis:
|
||||||
|
address: redis:6379
|
||||||
|
|
||||||
|
keys:
|
||||||
|
# API key : API secret (generated by setup script)
|
||||||
|
# devkey: secret
|
||||||
|
__LIVEKIT_API_KEY__: __LIVEKIT_API_SECRET__
|
||||||
|
|
||||||
|
webhook:
|
||||||
|
urls:
|
||||||
|
- http://server:1250/v1/livekit/webhook
|
||||||
|
api_key: __LIVEKIT_API_KEY__
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level: info
|
||||||
|
|
||||||
|
# Room settings
|
||||||
|
room:
|
||||||
|
empty_timeout: 300 # 5 minutes after last participant leaves
|
||||||
|
max_participants: 0 # 0 = unlimited
|
||||||
|
|
||||||
|
# Track Egress only (no composite video)
|
||||||
|
# Egress is configured via egress.yaml on the egress service
|
||||||
@@ -26,6 +26,12 @@
|
|||||||
# (If omitted, configure an external OpenAI-compatible LLM in server/.env)
|
# (If omitted, configure an external OpenAI-compatible LLM in server/.env)
|
||||||
#
|
#
|
||||||
# Optional flags:
|
# Optional flags:
|
||||||
|
# --livekit Enable LiveKit self-hosted video platform (generates credentials,
|
||||||
|
# starts livekit-server + livekit-egress containers)
|
||||||
|
# --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
|
# --garage Use Garage for local S3-compatible storage
|
||||||
# --caddy Enable Caddy reverse proxy with auto-SSL
|
# --caddy Enable Caddy reverse proxy with auto-SSL
|
||||||
# --domain DOMAIN Use a real domain for Caddy (enables Let's Encrypt auto-HTTPS)
|
# --domain DOMAIN Use a real domain for Caddy (enables Let's Encrypt auto-HTTPS)
|
||||||
@@ -42,10 +48,10 @@
|
|||||||
# --build Build backend and frontend images from source instead of pulling
|
# --build Build backend and frontend images from source instead of pulling
|
||||||
#
|
#
|
||||||
# Examples:
|
# Examples:
|
||||||
# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy
|
# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy
|
||||||
# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --garage --caddy --domain reflector.example.com
|
# ./scripts/setup-selfhosted.sh --gpu --ollama-gpu --livekit --garage --caddy --domain reflector.example.com
|
||||||
# ./scripts/setup-selfhosted.sh --cpu --ollama-cpu --garage --caddy
|
# ./scripts/setup-selfhosted.sh --cpu --ollama-cpu --livekit --garage --caddy
|
||||||
# ./scripts/setup-selfhosted.sh --hosted --garage --caddy
|
# ./scripts/setup-selfhosted.sh --hosted --livekit --garage --caddy
|
||||||
# ./scripts/setup-selfhosted.sh --cpu --padding modal --garage --caddy
|
# ./scripts/setup-selfhosted.sh --cpu --padding modal --garage --caddy
|
||||||
# ./scripts/setup-selfhosted.sh --gpu --translation passthrough --garage --caddy
|
# ./scripts/setup-selfhosted.sh --gpu --translation passthrough --garage --caddy
|
||||||
# ./scripts/setup-selfhosted.sh --cpu --diarization modal --translation modal --garage
|
# ./scripts/setup-selfhosted.sh --cpu --diarization modal --translation modal --garage
|
||||||
@@ -58,9 +64,11 @@
|
|||||||
# Config memory: after a successful run, flags are saved to data/.selfhosted-last-args.
|
# Config memory: after a successful run, flags are saved to data/.selfhosted-last-args.
|
||||||
# Re-running with no arguments replays the saved configuration automatically.
|
# Re-running with no arguments replays the saved configuration automatically.
|
||||||
#
|
#
|
||||||
# The script auto-detects Daily.co (DAILY_API_KEY) and Whereby (WHEREBY_API_KEY)
|
# The script auto-detects Daily.co (DAILY_API_KEY), Whereby (WHEREBY_API_KEY),
|
||||||
# from server/.env. If Daily.co is configured, Hatchet workflow services are
|
# and LiveKit (LIVEKIT_API_KEY) from server/.env.
|
||||||
# started automatically for multitrack recording processing.
|
# - Daily.co: enables Hatchet workflow services for multitrack recording processing.
|
||||||
|
# - LiveKit: enables livekit-server + livekit-egress containers (self-hosted,
|
||||||
|
# generates livekit.yaml and egress.yaml configs automatically).
|
||||||
#
|
#
|
||||||
# Idempotent — safe to re-run at any time.
|
# Idempotent — safe to re-run at any time.
|
||||||
#
|
#
|
||||||
@@ -207,8 +215,10 @@ fi
|
|||||||
MODEL_MODE="" # gpu or cpu (required, mutually exclusive)
|
MODEL_MODE="" # gpu or cpu (required, mutually exclusive)
|
||||||
OLLAMA_MODE="" # ollama-gpu or ollama-cpu (optional)
|
OLLAMA_MODE="" # ollama-gpu or ollama-cpu (optional)
|
||||||
USE_GARAGE=false
|
USE_GARAGE=false
|
||||||
|
USE_LIVEKIT=false
|
||||||
USE_CADDY=false
|
USE_CADDY=false
|
||||||
CUSTOM_DOMAIN="" # optional domain for Let's Encrypt HTTPS
|
CUSTOM_DOMAIN="" # optional domain for Let's Encrypt HTTPS
|
||||||
|
CUSTOM_IP="" # optional --ip override (mutually exclusive with --caddy)
|
||||||
BUILD_IMAGES=false # build backend/frontend from source
|
BUILD_IMAGES=false # build backend/frontend from source
|
||||||
ADMIN_PASSWORD="" # optional admin password for password auth
|
ADMIN_PASSWORD="" # optional admin password for password auth
|
||||||
CUSTOM_CA="" # --custom-ca: path to dir or CA cert file
|
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]}"
|
OLLAMA_MODEL="${ARGS[$next_i]}"
|
||||||
SKIP_NEXT=true ;;
|
SKIP_NEXT=true ;;
|
||||||
--garage) USE_GARAGE=true ;;
|
--garage) USE_GARAGE=true ;;
|
||||||
|
--livekit) USE_LIVEKIT=true ;;
|
||||||
--caddy) USE_CADDY=true ;;
|
--caddy) USE_CADDY=true ;;
|
||||||
|
--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 ;;
|
--build) BUILD_IMAGES=true ;;
|
||||||
--password)
|
--password)
|
||||||
next_i=$((i + 1))
|
next_i=$((i + 1))
|
||||||
@@ -356,6 +375,16 @@ for i in "${!ARGS[@]}"; do
|
|||||||
esac
|
esac
|
||||||
done
|
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) ---
|
# --- Save CLI args for config memory (re-run without flags) ---
|
||||||
if [[ $# -gt 0 ]]; then
|
if [[ $# -gt 0 ]]; then
|
||||||
mkdir -p "$ROOT_DIR/data"
|
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)"
|
MODE_DISPLAY="$MODE_DISPLAY (overrides: transcript=$EFF_TRANSCRIPT, diarization=$EFF_DIARIZATION, translation=$EFF_TRANSLATION, padding=$EFF_PADDING, mixdown=$EFF_MIXDOWN)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# LiveKit config generation helper
|
||||||
|
# =========================================================
|
||||||
|
_generate_livekit_config() {
|
||||||
|
local lk_key lk_secret lk_url
|
||||||
|
lk_key=$(env_get "$SERVER_ENV" "LIVEKIT_API_KEY" || true)
|
||||||
|
lk_secret=$(env_get "$SERVER_ENV" "LIVEKIT_API_SECRET" || true)
|
||||||
|
lk_url=$(env_get "$SERVER_ENV" "LIVEKIT_URL" || true)
|
||||||
|
|
||||||
|
if [[ -z "$lk_key" ]] || [[ -z "$lk_secret" ]]; then
|
||||||
|
warn "LIVEKIT_API_KEY or LIVEKIT_API_SECRET not set — generating random credentials"
|
||||||
|
lk_key="reflector_$(openssl rand -hex 8)"
|
||||||
|
lk_secret="$(openssl rand -hex 32)"
|
||||||
|
env_set "$SERVER_ENV" "LIVEKIT_API_KEY" "$lk_key"
|
||||||
|
env_set "$SERVER_ENV" "LIVEKIT_API_SECRET" "$lk_secret"
|
||||||
|
env_set "$SERVER_ENV" "LIVEKIT_URL" "ws://livekit-server:7880"
|
||||||
|
ok "Generated LiveKit API credentials"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set internal URL for server->livekit communication
|
||||||
|
if ! env_has_key "$SERVER_ENV" "LIVEKIT_URL" || [[ -z "$(env_get "$SERVER_ENV" "LIVEKIT_URL" || true)" ]]; then
|
||||||
|
env_set "$SERVER_ENV" "LIVEKIT_URL" "ws://livekit-server:7880"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set public URL based on deployment mode.
|
||||||
|
# When Caddy is enabled (HTTPS), LiveKit WebSocket is proxied through Caddy
|
||||||
|
# at /lk-ws to avoid mixed-content blocking (browsers block ws:// on https:// pages).
|
||||||
|
# When no Caddy, browsers connect directly to LiveKit on port 7880.
|
||||||
|
local public_lk_url
|
||||||
|
if [[ "$USE_CADDY" == "true" ]]; then
|
||||||
|
if [[ -n "$CUSTOM_DOMAIN" ]]; then
|
||||||
|
public_lk_url="wss://${CUSTOM_DOMAIN}/lk-ws"
|
||||||
|
elif [[ -n "$PRIMARY_IP" ]]; then
|
||||||
|
public_lk_url="wss://${PRIMARY_IP}/lk-ws"
|
||||||
|
else
|
||||||
|
public_lk_url="wss://localhost/lk-ws"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ -n "$PRIMARY_IP" ]]; then
|
||||||
|
public_lk_url="ws://${PRIMARY_IP}:7880"
|
||||||
|
else
|
||||||
|
public_lk_url="ws://localhost:7880"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
env_set "$SERVER_ENV" "LIVEKIT_PUBLIC_URL" "$public_lk_url"
|
||||||
|
env_set "$SERVER_ENV" "DEFAULT_VIDEO_PLATFORM" "livekit"
|
||||||
|
|
||||||
|
# LiveKit storage: 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
|
# Step 0: Prerequisites
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -737,13 +872,23 @@ step_server_env() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
if [[ -n "$PRIMARY_IP" ]]; then
|
if [[ -n "$PRIMARY_IP" ]]; then
|
||||||
server_base_url="http://$PRIMARY_IP"
|
server_base_url="http://$PRIMARY_IP:1250"
|
||||||
else
|
else
|
||||||
server_base_url="http://localhost:1250"
|
server_base_url="http://localhost:1250"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
env_set "$SERVER_ENV" "BASE_URL" "$server_base_url"
|
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
|
# WebRTC: advertise host IP in ICE candidates so browsers can reach the server
|
||||||
if [[ -n "$PRIMARY_IP" ]]; then
|
if [[ -n "$PRIMARY_IP" ]]; then
|
||||||
@@ -951,8 +1096,21 @@ step_server_env() {
|
|||||||
# Hatchet is always required (file, live, and multitrack pipelines all use it)
|
# 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_SERVER_URL" "http://hatchet:8888"
|
||||||
env_set "$SERVER_ENV" "HATCHET_CLIENT_HOST_PORT" "hatchet:7077"
|
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)"
|
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"
|
ok "server/.env ready"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,18 +1138,26 @@ step_www_env() {
|
|||||||
base_url="https://localhost"
|
base_url="https://localhost"
|
||||||
fi
|
fi
|
||||||
else
|
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
|
if [[ -n "$PRIMARY_IP" ]]; then
|
||||||
base_url="http://$PRIMARY_IP"
|
base_url="http://$PRIMARY_IP:3000"
|
||||||
else
|
else
|
||||||
base_url="http://localhost"
|
base_url="http://localhost:3000"
|
||||||
fi
|
fi
|
||||||
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" "SITE_URL" "$base_url"
|
||||||
env_set "$WWW_ENV" "NEXTAUTH_URL" "$base_url"
|
env_set "$WWW_ENV" "NEXTAUTH_URL" "$base_url"
|
||||||
env_set "$WWW_ENV" "NEXTAUTH_SECRET" "$NEXTAUTH_SECRET"
|
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" "WEBSOCKET_URL" "auto"
|
||||||
env_set "$WWW_ENV" "SERVER_API_URL" "http://server:1250"
|
env_set "$WWW_ENV" "SERVER_API_URL" "http://server:1250"
|
||||||
env_set "$WWW_ENV" "KV_URL" "redis://redis:6379"
|
env_set "$WWW_ENV" "KV_URL" "redis://redis:6379"
|
||||||
@@ -1014,14 +1180,17 @@ step_www_env() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Enable rooms if any video platform is configured in server/.env
|
# Enable rooms if any video platform is configured in server/.env
|
||||||
local _daily_key="" _whereby_key=""
|
local _daily_key="" _whereby_key="" _livekit_key=""
|
||||||
if env_has_key "$SERVER_ENV" "DAILY_API_KEY"; then
|
if env_has_key "$SERVER_ENV" "DAILY_API_KEY"; then
|
||||||
_daily_key=$(env_get "$SERVER_ENV" "DAILY_API_KEY")
|
_daily_key=$(env_get "$SERVER_ENV" "DAILY_API_KEY")
|
||||||
fi
|
fi
|
||||||
if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY"; then
|
if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY"; then
|
||||||
_whereby_key=$(env_get "$SERVER_ENV" "WHEREBY_API_KEY")
|
_whereby_key=$(env_get "$SERVER_ENV" "WHEREBY_API_KEY")
|
||||||
fi
|
fi
|
||||||
if [[ -n "$_daily_key" ]] || [[ -n "$_whereby_key" ]]; then
|
if env_has_key "$SERVER_ENV" "LIVEKIT_API_KEY"; then
|
||||||
|
_livekit_key=$(env_get "$SERVER_ENV" "LIVEKIT_API_KEY")
|
||||||
|
fi
|
||||||
|
if [[ -n "$_daily_key" ]] || [[ -n "$_whereby_key" ]] || [[ -n "$_livekit_key" ]]; then
|
||||||
env_set "$WWW_ENV" "FEATURE_ROOMS" "true"
|
env_set "$WWW_ENV" "FEATURE_ROOMS" "true"
|
||||||
ok "Rooms feature enabled (video platform configured)"
|
ok "Rooms feature enabled (video platform configured)"
|
||||||
fi
|
fi
|
||||||
@@ -1110,7 +1279,13 @@ step_garage() {
|
|||||||
|
|
||||||
# Write S3 credentials to server/.env
|
# Write S3 credentials to server/.env
|
||||||
env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_BACKEND" "aws"
|
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_BUCKET_NAME" "reflector-media"
|
||||||
env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_REGION" "garage"
|
env_set "$SERVER_ENV" "TRANSCRIPT_STORAGE_AWS_REGION" "garage"
|
||||||
if [[ "$created_key" == "true" ]]; then
|
if [[ "$created_key" == "true" ]]; then
|
||||||
@@ -1188,6 +1363,22 @@ step_caddyfile() {
|
|||||||
rm -rf "$caddyfile"
|
rm -rf "$caddyfile"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# LiveKit reverse proxy snippet (inserted into Caddyfile when --livekit is active)
|
||||||
|
# LiveKit reverse proxy snippet (inserted into Caddyfile when --livekit is active).
|
||||||
|
# Strips /lk-ws prefix so LiveKit server sees requests at its root /.
|
||||||
|
local lk_proxy_block=""
|
||||||
|
if [[ "$LIVEKIT_DETECTED" == "true" ]]; then
|
||||||
|
lk_proxy_block="
|
||||||
|
handle_path /lk-ws/* {
|
||||||
|
reverse_proxy livekit-server:7880
|
||||||
|
}
|
||||||
|
handle_path /lk-ws {
|
||||||
|
reverse_proxy livekit-server:7880
|
||||||
|
}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local hatchet_proxy_block=""
|
||||||
|
|
||||||
if [[ -n "$TLS_CERT_PATH" ]] && [[ -n "$CUSTOM_DOMAIN" ]]; then
|
if [[ -n "$TLS_CERT_PATH" ]] && [[ -n "$CUSTOM_DOMAIN" ]]; then
|
||||||
# Custom domain with user-provided TLS certificate (from --custom-ca directory)
|
# Custom domain with user-provided TLS certificate (from --custom-ca directory)
|
||||||
cat > "$caddyfile" << CADDYEOF
|
cat > "$caddyfile" << CADDYEOF
|
||||||
@@ -1199,7 +1390,7 @@ $CUSTOM_DOMAIN {
|
|||||||
}
|
}
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy server:1250
|
reverse_proxy server:1250
|
||||||
}
|
}${lk_proxy_block}${hatchet_proxy_block}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
}
|
}
|
||||||
@@ -1216,7 +1407,7 @@ $CUSTOM_DOMAIN {
|
|||||||
}
|
}
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy server:1250
|
reverse_proxy server:1250
|
||||||
}
|
}${lk_proxy_block}${hatchet_proxy_block}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
}
|
}
|
||||||
@@ -1225,17 +1416,19 @@ CADDYEOF
|
|||||||
ok "Created Caddyfile for $CUSTOM_DOMAIN (Let's Encrypt auto-HTTPS)"
|
ok "Created Caddyfile for $CUSTOM_DOMAIN (Let's Encrypt auto-HTTPS)"
|
||||||
elif [[ -n "$PRIMARY_IP" ]]; then
|
elif [[ -n "$PRIMARY_IP" ]]; then
|
||||||
# No domain, IP only: catch-all :443 with self-signed cert
|
# 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
|
cat > "$caddyfile" << CADDYEOF
|
||||||
# Generated by setup-selfhosted.sh — self-signed cert for IP access
|
# Generated by setup-selfhosted.sh — self-signed cert for IP access
|
||||||
:443 {
|
:443 {
|
||||||
tls internal
|
tls internal {
|
||||||
|
on_demand
|
||||||
|
}
|
||||||
handle /v1/* {
|
handle /v1/* {
|
||||||
reverse_proxy server:1250
|
reverse_proxy server:1250
|
||||||
}
|
}
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy server:1250
|
reverse_proxy server:1250
|
||||||
}
|
}${lk_proxy_block}${hatchet_proxy_block}
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy web:3000
|
reverse_proxy web:3000
|
||||||
}
|
}
|
||||||
@@ -1249,21 +1442,8 @@ CADDYEOF
|
|||||||
ok "Caddyfile already exists"
|
ok "Caddyfile already exists"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Add Hatchet dashboard route if Daily.co is detected
|
if [[ "$DAILY_DETECTED" == "true" ]] || [[ "$LIVEKIT_DETECTED" == "true" ]]; then
|
||||||
if [[ "$DAILY_DETECTED" == "true" ]]; then
|
ok "Hatchet dashboard available at port 8888"
|
||||||
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
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1467,7 +1647,7 @@ step_health() {
|
|||||||
info "Waiting for Hatchet workflow engine..."
|
info "Waiting for Hatchet workflow engine..."
|
||||||
local hatchet_ok=false
|
local hatchet_ok=false
|
||||||
for i in $(seq 1 60); do
|
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
|
hatchet_ok=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -1515,7 +1695,7 @@ step_hatchet_token() {
|
|||||||
# Wait for hatchet to be healthy
|
# Wait for hatchet to be healthy
|
||||||
local hatchet_ok=false
|
local hatchet_ok=false
|
||||||
for i in $(seq 1 60); do
|
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
|
hatchet_ok=true
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
@@ -1586,12 +1766,19 @@ main() {
|
|||||||
[[ "$BUILD_IMAGES" == "true" ]] && echo " Build: from source"
|
[[ "$BUILD_IMAGES" == "true" ]] && echo " Build: from source"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Detect primary IP
|
# Detect primary IP (--ip overrides auto-detection)
|
||||||
PRIMARY_IP=""
|
if [[ -n "$CUSTOM_IP" ]]; then
|
||||||
if [[ "$OS" == "Linux" ]]; then
|
PRIMARY_IP="$CUSTOM_IP"
|
||||||
PRIMARY_IP=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
|
ok "Using provided IP: $PRIMARY_IP"
|
||||||
if [[ "$PRIMARY_IP" == "127."* ]] || [[ -z "$PRIMARY_IP" ]]; then
|
else
|
||||||
PRIMARY_IP=$(ip -4 route get 1 2>/dev/null | sed -n 's/.*src \([0-9.]*\).*/\1/p' || true)
|
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
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1621,14 +1808,21 @@ main() {
|
|||||||
# Auto-detect video platforms from server/.env (after step_server_env so file exists)
|
# Auto-detect video platforms from server/.env (after step_server_env so file exists)
|
||||||
DAILY_DETECTED=false
|
DAILY_DETECTED=false
|
||||||
WHEREBY_DETECTED=false
|
WHEREBY_DETECTED=false
|
||||||
|
LIVEKIT_DETECTED=false
|
||||||
if env_has_key "$SERVER_ENV" "DAILY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "DAILY_API_KEY")" ]]; then
|
if env_has_key "$SERVER_ENV" "DAILY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "DAILY_API_KEY")" ]]; then
|
||||||
DAILY_DETECTED=true
|
DAILY_DETECTED=true
|
||||||
fi
|
fi
|
||||||
if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "WHEREBY_API_KEY")" ]]; then
|
if env_has_key "$SERVER_ENV" "WHEREBY_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "WHEREBY_API_KEY")" ]]; then
|
||||||
WHEREBY_DETECTED=true
|
WHEREBY_DETECTED=true
|
||||||
fi
|
fi
|
||||||
|
# LiveKit: enabled via --livekit flag OR pre-existing LIVEKIT_API_KEY in env
|
||||||
|
if [[ "$USE_LIVEKIT" == "true" ]]; then
|
||||||
|
LIVEKIT_DETECTED=true
|
||||||
|
elif env_has_key "$SERVER_ENV" "LIVEKIT_API_KEY" && [[ -n "$(env_get "$SERVER_ENV" "LIVEKIT_API_KEY")" ]]; then
|
||||||
|
LIVEKIT_DETECTED=true
|
||||||
|
fi
|
||||||
ANY_PLATFORM_DETECTED=false
|
ANY_PLATFORM_DETECTED=false
|
||||||
[[ "$DAILY_DETECTED" == "true" || "$WHEREBY_DETECTED" == "true" ]] && ANY_PLATFORM_DETECTED=true
|
[[ "$DAILY_DETECTED" == "true" || "$WHEREBY_DETECTED" == "true" || "$LIVEKIT_DETECTED" == "true" ]] && ANY_PLATFORM_DETECTED=true
|
||||||
|
|
||||||
# Conditional profile activation for Daily.co
|
# Conditional profile activation for Daily.co
|
||||||
if [[ "$DAILY_DETECTED" == "true" ]]; then
|
if [[ "$DAILY_DETECTED" == "true" ]]; then
|
||||||
@@ -1636,6 +1830,13 @@ main() {
|
|||||||
ok "Daily.co detected — enabling Hatchet workflow services"
|
ok "Daily.co detected — enabling Hatchet workflow services"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Conditional profile activation for LiveKit
|
||||||
|
if [[ "$LIVEKIT_DETECTED" == "true" ]]; then
|
||||||
|
COMPOSE_PROFILES+=("livekit")
|
||||||
|
_generate_livekit_config
|
||||||
|
ok "LiveKit enabled — livekit-server + livekit-egress"
|
||||||
|
fi
|
||||||
|
|
||||||
# Generate .env.hatchet for hatchet dashboard config (always needed)
|
# Generate .env.hatchet for hatchet dashboard config (always needed)
|
||||||
local hatchet_server_url hatchet_cookie_domain
|
local hatchet_server_url hatchet_cookie_domain
|
||||||
if [[ -n "$CUSTOM_DOMAIN" ]]; then
|
if [[ -n "$CUSTOM_DOMAIN" ]]; then
|
||||||
@@ -1683,10 +1884,12 @@ EOF
|
|||||||
echo " App: https://localhost (accept self-signed cert in browser)"
|
echo " App: https://localhost (accept self-signed cert in browser)"
|
||||||
echo " API: https://localhost/v1/"
|
echo " API: https://localhost/v1/"
|
||||||
fi
|
fi
|
||||||
|
elif [[ -n "$PRIMARY_IP" ]]; then
|
||||||
|
echo " App: http://$PRIMARY_IP:3000"
|
||||||
|
echo " API: http://$PRIMARY_IP:1250"
|
||||||
else
|
else
|
||||||
echo " No Caddy — point your reverse proxy at:"
|
echo " App: http://localhost:3000"
|
||||||
echo " Frontend: web:3000 (or localhost:3000 from host)"
|
echo " API: http://localhost:1250"
|
||||||
echo " API: server:1250 (or localhost:1250 from host)"
|
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
if [[ "$HAS_OVERRIDES" == "true" ]]; then
|
if [[ "$HAS_OVERRIDES" == "true" ]]; then
|
||||||
@@ -1702,6 +1905,7 @@ EOF
|
|||||||
[[ "$USES_OLLAMA" != "true" ]] && echo " LLM: External (configure in server/.env)"
|
[[ "$USES_OLLAMA" != "true" ]] && echo " LLM: External (configure in server/.env)"
|
||||||
[[ "$DAILY_DETECTED" == "true" ]] && echo " Video: Daily.co (live rooms + multitrack processing via Hatchet)"
|
[[ "$DAILY_DETECTED" == "true" ]] && echo " Video: Daily.co (live rooms + multitrack processing via Hatchet)"
|
||||||
[[ "$WHEREBY_DETECTED" == "true" ]] && echo " Video: Whereby (live rooms)"
|
[[ "$WHEREBY_DETECTED" == "true" ]] && echo " Video: Whereby (live rooms)"
|
||||||
|
[[ "$LIVEKIT_DETECTED" == "true" ]] && echo " Video: LiveKit (self-hosted, live rooms + track egress)"
|
||||||
[[ "$ANY_PLATFORM_DETECTED" != "true" ]] && echo " Video: None (rooms disabled)"
|
[[ "$ANY_PLATFORM_DETECTED" != "true" ]] && echo " Video: None (rooms disabled)"
|
||||||
if [[ "$USE_CUSTOM_CA" == "true" ]]; then
|
if [[ "$USE_CUSTOM_CA" == "true" ]]; then
|
||||||
echo " CA: Custom (certs/ca.crt)"
|
echo " CA: Custom (certs/ca.crt)"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ dependencies = [
|
|||||||
"pydantic>=2.12.5",
|
"pydantic>=2.12.5",
|
||||||
"aiosmtplib>=3.0.0",
|
"aiosmtplib>=3.0.0",
|
||||||
"email-validator>=2.0.0",
|
"email-validator>=2.0.0",
|
||||||
|
"livekit-api>=1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from reflector.metrics import metrics_init
|
|||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
from reflector.views.config import router as config_router
|
from reflector.views.config import router as config_router
|
||||||
from reflector.views.daily import router as daily_router
|
from reflector.views.daily import router as daily_router
|
||||||
|
from reflector.views.livekit import router as livekit_router
|
||||||
from reflector.views.meetings import router as meetings_router
|
from reflector.views.meetings import router as meetings_router
|
||||||
from reflector.views.rooms import router as rooms_router
|
from reflector.views.rooms import router as rooms_router
|
||||||
from reflector.views.rtc_offer import router as rtc_offer_router
|
from reflector.views.rtc_offer import router as rtc_offer_router
|
||||||
@@ -112,6 +113,7 @@ app.include_router(config_router, prefix="/v1")
|
|||||||
app.include_router(zulip_router, prefix="/v1")
|
app.include_router(zulip_router, prefix="/v1")
|
||||||
app.include_router(whereby_router, prefix="/v1")
|
app.include_router(whereby_router, prefix="/v1")
|
||||||
app.include_router(daily_router, prefix="/v1/daily")
|
app.include_router(daily_router, prefix="/v1/daily")
|
||||||
|
app.include_router(livekit_router, prefix="/v1/livekit")
|
||||||
if auth_router:
|
if auth_router:
|
||||||
app.include_router(auth_router, prefix="/v1")
|
app.include_router(auth_router, prefix="/v1")
|
||||||
add_pagination(app)
|
add_pagination(app)
|
||||||
|
|||||||
@@ -165,6 +165,17 @@ class MeetingController:
|
|||||||
results = await get_database().fetch_all(query)
|
results = await get_database().fetch_all(query)
|
||||||
return [Meeting(**result) for result in results]
|
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(
|
async def get_by_room_name(
|
||||||
self,
|
self,
|
||||||
room_name: str,
|
room_name: str,
|
||||||
|
|||||||
@@ -486,6 +486,14 @@ class TranscriptController:
|
|||||||
return None
|
return None
|
||||||
return Transcript(**result)
|
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(
|
async def get_by_recording_id(
|
||||||
self, recording_id: str, **kwargs
|
self, recording_id: str, **kwargs
|
||||||
) -> Transcript | None:
|
) -> Transcript | None:
|
||||||
|
|||||||
@@ -273,8 +273,10 @@ def with_error_handling(
|
|||||||
)
|
)
|
||||||
@with_error_handling(TaskName.GET_RECORDING)
|
@with_error_handling(TaskName.GET_RECORDING)
|
||||||
async def get_recording(input: PipelineInput, ctx: Context) -> RecordingResult:
|
async def get_recording(input: PipelineInput, ctx: Context) -> RecordingResult:
|
||||||
"""Fetch recording metadata from Daily.co API."""
|
"""Fetch recording metadata. Platform-aware: Daily calls API, LiveKit skips."""
|
||||||
ctx.log(f"get_recording: starting for recording_id={input.recording_id}")
|
ctx.log(
|
||||||
|
f"get_recording: starting for recording_id={input.recording_id}, platform={input.source_platform}"
|
||||||
|
)
|
||||||
ctx.log(
|
ctx.log(
|
||||||
f"get_recording: transcript_id={input.transcript_id}, room_id={input.room_id}"
|
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")
|
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:
|
if not settings.DAILY_API_KEY:
|
||||||
ctx.log("get_recording: ERROR - DAILY_API_KEY not configured")
|
ctx.log("get_recording: ERROR - DAILY_API_KEY not configured")
|
||||||
raise ValueError("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)
|
@with_error_handling(TaskName.GET_PARTICIPANTS)
|
||||||
async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsResult:
|
async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsResult:
|
||||||
"""Fetch participant list from Daily.co API and update transcript in database."""
|
"""Fetch participant list and update transcript. Platform-aware."""
|
||||||
ctx.log(f"get_participants: transcript_id={input.transcript_id}")
|
ctx.log(
|
||||||
|
f"get_participants: transcript_id={input.transcript_id}, platform={input.source_platform}"
|
||||||
|
)
|
||||||
|
|
||||||
recording = ctx.task_output(get_recording)
|
recording = ctx.task_output(get_recording)
|
||||||
mtg_session_id = recording.mtg_session_id
|
|
||||||
async with fresh_db_connection():
|
async with fresh_db_connection():
|
||||||
from reflector.db.transcripts import ( # noqa: PLC0415
|
from reflector.db.transcripts import ( # noqa: PLC0415
|
||||||
TranscriptDuration,
|
TranscriptDuration,
|
||||||
@@ -347,8 +362,8 @@ async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsRe
|
|||||||
transcript = await transcripts_controller.get_by_id(input.transcript_id)
|
transcript = await transcripts_controller.get_by_id(input.transcript_id)
|
||||||
if not transcript:
|
if not transcript:
|
||||||
raise ValueError(f"Transcript {input.transcript_id} not found")
|
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
|
duration_ms = recording.duration * 1000 if recording.duration else 0
|
||||||
await transcripts_controller.update(
|
await transcripts_controller.update(
|
||||||
transcript,
|
transcript,
|
||||||
@@ -360,65 +375,141 @@ async def get_participants(input: PipelineInput, ctx: Context) -> ParticipantsRe
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
await append_event_and_broadcast(
|
if duration_ms:
|
||||||
input.transcript_id,
|
await append_event_and_broadcast(
|
||||||
transcript,
|
input.transcript_id,
|
||||||
"DURATION",
|
transcript,
|
||||||
TranscriptDuration(duration=duration_ms),
|
"DURATION",
|
||||||
logger=logger,
|
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)
|
|
||||||
|
|
||||||
participants_list: list[ParticipantInfo] = []
|
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:
|
try:
|
||||||
parsed = parse_daily_recording_filename(key)
|
from reflector.db.meetings import (
|
||||||
participant_id = parsed.participant_id
|
meetings_controller as mc, # noqa: PLC0415
|
||||||
except ValueError as e:
|
)
|
||||||
logger.error(
|
from reflector.redis_cache import (
|
||||||
"Failed to parse Daily recording filename",
|
get_async_redis_client, # noqa: PLC0415
|
||||||
error=str(e),
|
|
||||||
key=key,
|
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
|
|
||||||
default_name = f"Speaker {idx}"
|
meeting = (
|
||||||
name = id_to_name.get(participant_id, default_name)
|
await mc.get_by_id(transcript.meeting_id)
|
||||||
user_id = id_to_user_id.get(participant_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(
|
for idx, track in enumerate(input.tracks):
|
||||||
id=participant_id, speaker=idx, name=name, user_id=user_id
|
identity = track.get("participant_identity")
|
||||||
)
|
if not identity:
|
||||||
await transcripts_controller.upsert_participant(transcript, participant)
|
# Reprocess path: parse from S3 key
|
||||||
participants_list.append(
|
try:
|
||||||
ParticipantInfo(
|
parsed = parse_livekit_track_filepath(track["s3_key"])
|
||||||
participant_id=participant_id,
|
identity = parsed.participant_identity
|
||||||
user_name=name,
|
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,
|
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")
|
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)
|
@with_error_handling(TaskName.PROCESS_TRACKS)
|
||||||
async def process_tracks(input: PipelineInput, ctx: Context) -> ProcessTracksResult:
|
async def process_tracks(input: PipelineInput, ctx: Context) -> ProcessTracksResult:
|
||||||
"""Spawn child workflows for each track (dynamic fan-out)."""
|
"""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)
|
participants_result = ctx.task_output(get_participants)
|
||||||
source_language = participants_result.source_language
|
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 = [
|
bulk_runs = [
|
||||||
track_workflow.create_bulk_run_item(
|
track_workflow.create_bulk_run_item(
|
||||||
input=TrackInput(
|
input=TrackInput(
|
||||||
@@ -454,6 +600,7 @@ async def process_tracks(input: PipelineInput, ctx: Context) -> ProcessTracksRes
|
|||||||
transcript_id=input.transcript_id,
|
transcript_id=input.transcript_id,
|
||||||
language=source_language,
|
language=source_language,
|
||||||
source_platform=input.source_platform,
|
source_platform=input.source_platform,
|
||||||
|
padding_seconds=track_padding.get(i),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for i, track in enumerate(input.tracks)
|
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
|
# else: modal backend already uploaded to output_url
|
||||||
|
|
||||||
async with fresh_db_connection():
|
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)
|
transcript = await transcripts_controller.get_by_id(input.transcript_id)
|
||||||
if transcript:
|
if transcript:
|
||||||
await transcripts_controller.update(
|
update_data = {"audio_location": "storage"}
|
||||||
transcript, {"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}")
|
ctx.log(f"mixdown_tracks complete: {result.size} bytes to {storage_path}")
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class TrackInput(BaseModel):
|
|||||||
transcript_id: str
|
transcript_id: str
|
||||||
language: str = "en"
|
language: str = "en"
|
||||||
source_platform: str = "daily"
|
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()
|
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:
|
async def pad_track(input: TrackInput, ctx: Context) -> PadTrackResult:
|
||||||
"""Pad single audio track with silence for alignment.
|
"""Pad single audio track with silence for alignment.
|
||||||
|
|
||||||
Extracts stream.start_time from WebM container metadata and applies
|
For Daily: extracts stream.start_time from WebM container metadata.
|
||||||
silence padding using PyAV filter graph (adelay).
|
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(
|
logger.info(
|
||||||
"[Hatchet] pad_track",
|
"[Hatchet] pad_track",
|
||||||
track_index=input.track_index,
|
track_index=input.track_index,
|
||||||
s3_key=input.s3_key,
|
s3_key=input.s3_key,
|
||||||
transcript_id=input.transcript_id,
|
transcript_id=input.transcript_id,
|
||||||
|
padding_seconds=input.padding_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -79,10 +86,16 @@ async def pad_track(input: TrackInput, ctx: Context) -> PadTrackResult:
|
|||||||
bucket=input.bucket_name,
|
bucket=input.bucket_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
with av.open(source_url) as in_container:
|
if input.padding_seconds is not None:
|
||||||
start_time_seconds = extract_stream_start_time_from_container(
|
# Pre-calculated offset (LiveKit: from filename timestamps)
|
||||||
in_container, input.track_index, logger=logger
|
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 no padding needed, return original S3 key
|
||||||
if start_time_seconds <= 0:
|
if start_time_seconds <= 0:
|
||||||
|
|||||||
12
server/reflector/livekit_api/__init__.py
Normal file
12
server/reflector/livekit_api/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"""
|
||||||
|
LiveKit API Module — thin wrapper around the livekit-api SDK.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .client import LiveKitApiClient
|
||||||
|
from .webhooks import create_webhook_receiver, verify_webhook
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LiveKitApiClient",
|
||||||
|
"create_webhook_receiver",
|
||||||
|
"verify_webhook",
|
||||||
|
]
|
||||||
195
server/reflector/livekit_api/client.py
Normal file
195
server/reflector/livekit_api/client.py
Normal file
@@ -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()
|
||||||
52
server/reflector/livekit_api/webhooks.py
Normal file
52
server/reflector/livekit_api/webhooks.py
Normal file
@@ -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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
Platform = Literal["whereby", "daily"]
|
Platform = Literal["whereby", "daily", "livekit"]
|
||||||
WHEREBY_PLATFORM: Platform = "whereby"
|
WHEREBY_PLATFORM: Platform = "whereby"
|
||||||
DAILY_PLATFORM: Platform = "daily"
|
DAILY_PLATFORM: Platform = "daily"
|
||||||
|
LIVEKIT_PLATFORM: Platform = "livekit"
|
||||||
|
|||||||
@@ -155,12 +155,17 @@ async def prepare_transcript_processing(validation: ValidationOk) -> PrepareResu
|
|||||||
)
|
)
|
||||||
|
|
||||||
if track_keys:
|
if track_keys:
|
||||||
|
# Detect platform from recording ID prefix
|
||||||
|
source_platform = (
|
||||||
|
"livekit" if recording_id and recording_id.startswith("lk-") else "daily"
|
||||||
|
)
|
||||||
return MultitrackProcessingConfig(
|
return MultitrackProcessingConfig(
|
||||||
bucket_name=bucket_name, # type: ignore (validated above)
|
bucket_name=bucket_name, # type: ignore (validated above)
|
||||||
track_keys=track_keys,
|
track_keys=track_keys,
|
||||||
transcript_id=validation.transcript_id,
|
transcript_id=validation.transcript_id,
|
||||||
recording_id=recording_id,
|
recording_id=recording_id,
|
||||||
room_id=validation.room_id,
|
room_id=validation.room_id,
|
||||||
|
source_platform=source_platform,
|
||||||
)
|
)
|
||||||
|
|
||||||
return FileProcessingConfig(
|
return FileProcessingConfig(
|
||||||
|
|||||||
@@ -195,6 +195,23 @@ class Settings(BaseSettings):
|
|||||||
DAILY_WEBHOOK_UUID: str | None = (
|
DAILY_WEBHOOK_UUID: str | None = (
|
||||||
None # Webhook UUID for this environment. Not used by production code
|
None # Webhook UUID for this environment. Not used by production code
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# LiveKit integration (self-hosted open-source video platform)
|
||||||
|
LIVEKIT_URL: str | None = (
|
||||||
|
None # e.g. ws://livekit:7880 (internal) or wss://livekit.example.com
|
||||||
|
)
|
||||||
|
LIVEKIT_API_KEY: str | None = None
|
||||||
|
LIVEKIT_API_SECRET: str | None = None
|
||||||
|
LIVEKIT_WEBHOOK_SECRET: str | None = None # Defaults to API_SECRET if not set
|
||||||
|
# LiveKit egress S3 storage (Track Egress writes per-participant audio here)
|
||||||
|
LIVEKIT_STORAGE_AWS_BUCKET_NAME: str | None = None
|
||||||
|
LIVEKIT_STORAGE_AWS_REGION: str | None = None
|
||||||
|
LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
|
||||||
|
LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
|
||||||
|
LIVEKIT_STORAGE_AWS_ENDPOINT_URL: str | None = None # For Garage/MinIO
|
||||||
|
# Public URL for LiveKit (used in frontend room_url, e.g. wss://livekit.example.com)
|
||||||
|
LIVEKIT_PUBLIC_URL: str | None = None
|
||||||
|
|
||||||
# Platform Configuration
|
# Platform Configuration
|
||||||
DEFAULT_VIDEO_PLATFORM: Platform = DAILY_PLATFORM
|
DEFAULT_VIDEO_PLATFORM: Platform = DAILY_PLATFORM
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,22 @@ def get_source_storage(platform: str) -> Storage:
|
|||||||
aws_secret_access_key=settings.WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY,
|
aws_secret_access_key=settings.WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
elif platform == "livekit":
|
||||||
|
if (
|
||||||
|
settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID
|
||||||
|
and settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY
|
||||||
|
and settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME
|
||||||
|
):
|
||||||
|
from reflector.storage.storage_aws import AwsStorage
|
||||||
|
|
||||||
|
return AwsStorage(
|
||||||
|
aws_bucket_name=settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME,
|
||||||
|
aws_region=settings.LIVEKIT_STORAGE_AWS_REGION or "us-east-1",
|
||||||
|
aws_access_key_id=settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY,
|
||||||
|
aws_endpoint_url=settings.LIVEKIT_STORAGE_AWS_ENDPOINT_URL,
|
||||||
|
)
|
||||||
|
|
||||||
return get_transcripts_storage()
|
return get_transcripts_storage()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
112
server/reflector/utils/livekit.py
Normal file
112
server/reflector/utils/livekit.py
Normal file
@@ -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<room_name>[^/]+)/(?P<identity>.+?)-(?P<timestamp>\d{4}-\d{2}-\d{2}T\d{6})-(?P<track_id>TR_\w+)\.(?P<ext>\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}"
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from reflector.settings import settings
|
from reflector.settings import settings
|
||||||
from reflector.storage import get_dailyco_storage, get_whereby_storage
|
from reflector.storage import get_dailyco_storage, get_whereby_storage
|
||||||
|
|
||||||
from ..schemas.platform import WHEREBY_PLATFORM, Platform
|
from ..schemas.platform import LIVEKIT_PLATFORM, WHEREBY_PLATFORM, Platform
|
||||||
from .base import VideoPlatformClient, VideoPlatformConfig
|
from .base import VideoPlatformClient, VideoPlatformConfig
|
||||||
from .registry import get_platform_client
|
from .registry import get_platform_client
|
||||||
|
|
||||||
@@ -44,6 +44,27 @@ def get_platform_config(platform: Platform) -> VideoPlatformConfig:
|
|||||||
s3_region=daily_storage.region,
|
s3_region=daily_storage.region,
|
||||||
aws_role_arn=daily_storage.role_credential,
|
aws_role_arn=daily_storage.role_credential,
|
||||||
)
|
)
|
||||||
|
elif platform == LIVEKIT_PLATFORM:
|
||||||
|
if not settings.LIVEKIT_URL:
|
||||||
|
raise ValueError(
|
||||||
|
"LIVEKIT_URL is required when platform='livekit'. "
|
||||||
|
"Set LIVEKIT_URL environment variable."
|
||||||
|
)
|
||||||
|
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
|
||||||
|
raise ValueError(
|
||||||
|
"LIVEKIT_API_KEY and LIVEKIT_API_SECRET are required when platform='livekit'. "
|
||||||
|
"Set LIVEKIT_API_KEY and LIVEKIT_API_SECRET environment variables."
|
||||||
|
)
|
||||||
|
return VideoPlatformConfig(
|
||||||
|
api_key=settings.LIVEKIT_API_KEY,
|
||||||
|
webhook_secret=settings.LIVEKIT_WEBHOOK_SECRET
|
||||||
|
or settings.LIVEKIT_API_SECRET,
|
||||||
|
api_url=settings.LIVEKIT_URL,
|
||||||
|
s3_bucket=settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME,
|
||||||
|
s3_region=settings.LIVEKIT_STORAGE_AWS_REGION,
|
||||||
|
aws_access_key_id=settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID,
|
||||||
|
aws_access_key_secret=settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown platform: {platform}")
|
raise ValueError(f"Unknown platform: {platform}")
|
||||||
|
|
||||||
|
|||||||
192
server/reflector/video_platforms/livekit.py
Normal file
192
server/reflector/video_platforms/livekit.py
Normal file
@@ -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=<name>&token=<jwt>
|
||||||
|
public_url = settings.LIVEKIT_PUBLIC_URL or settings.LIVEKIT_URL or ""
|
||||||
|
room_url = f"{public_url}?{urlencode({'room': lk_room.name})}"
|
||||||
|
|
||||||
|
return MeetingData(
|
||||||
|
meeting_id=lk_room.sid or str(uuid4()),
|
||||||
|
room_name=lk_room.name,
|
||||||
|
room_url=room_url,
|
||||||
|
host_room_url=room_url,
|
||||||
|
platform=self.PLATFORM_NAME,
|
||||||
|
extra_data={"livekit_room_sid": lk_room.sid},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_room_sessions(self, room_name: str) -> list[SessionData]:
|
||||||
|
"""Get current participants in a LiveKit room.
|
||||||
|
|
||||||
|
For historical sessions, we rely on webhook-stored data (same as Daily).
|
||||||
|
This returns currently-connected participants.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
participants = await self._api_client.list_participants(room_name)
|
||||||
|
return [
|
||||||
|
SessionData(
|
||||||
|
session_id=p.sid,
|
||||||
|
started_at=datetime.fromtimestamp(
|
||||||
|
p.joined_at if p.joined_at else 0, tz=timezone.utc
|
||||||
|
),
|
||||||
|
ended_at=None, # Still active
|
||||||
|
)
|
||||||
|
for p in participants
|
||||||
|
if p.sid # Skip empty entries
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(
|
||||||
|
"Could not list LiveKit participants (room may not exist)",
|
||||||
|
room_name=room_name,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
|
||||||
|
# LiveKit doesn't have a logo upload concept; handled in frontend theming
|
||||||
|
return True
|
||||||
|
|
||||||
|
def verify_webhook_signature(
|
||||||
|
self, body: bytes, signature: str, timestamp: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Verify LiveKit webhook signature.
|
||||||
|
|
||||||
|
LiveKit sends the JWT in the Authorization header. The `signature`
|
||||||
|
param here receives the Authorization header value.
|
||||||
|
"""
|
||||||
|
event = verify_webhook(self._webhook_receiver, body, signature)
|
||||||
|
return event is not None
|
||||||
|
|
||||||
|
def create_access_token(
|
||||||
|
self,
|
||||||
|
room_name: str,
|
||||||
|
participant_identity: str,
|
||||||
|
participant_name: str | None = None,
|
||||||
|
is_admin: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a LiveKit access token for a participant."""
|
||||||
|
return self._api_client.create_access_token(
|
||||||
|
room_name=room_name,
|
||||||
|
participant_identity=participant_identity,
|
||||||
|
participant_name=participant_name,
|
||||||
|
room_admin=is_admin,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start_track_egress(
|
||||||
|
self,
|
||||||
|
room_name: str,
|
||||||
|
track_sid: str,
|
||||||
|
s3_filepath: str,
|
||||||
|
):
|
||||||
|
"""Start Track Egress for a single audio track."""
|
||||||
|
return await self._api_client.start_track_egress(
|
||||||
|
room_name=room_name,
|
||||||
|
track_sid=track_sid,
|
||||||
|
s3_filepath=s3_filepath,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_egress(self, room_name: str | None = None):
|
||||||
|
return await self._api_client.list_egress(room_name=room_name)
|
||||||
|
|
||||||
|
async def stop_egress(self, egress_id: str):
|
||||||
|
return await self._api_client.stop_egress(egress_id=egress_id)
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
await self._api_client.close()
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
await self.close()
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
from typing import Dict, Type
|
from typing import Dict, Type
|
||||||
|
|
||||||
from ..schemas.platform import DAILY_PLATFORM, WHEREBY_PLATFORM, Platform
|
from ..schemas.platform import (
|
||||||
|
DAILY_PLATFORM,
|
||||||
|
LIVEKIT_PLATFORM,
|
||||||
|
WHEREBY_PLATFORM,
|
||||||
|
Platform,
|
||||||
|
)
|
||||||
from .base import VideoPlatformClient, VideoPlatformConfig
|
from .base import VideoPlatformClient, VideoPlatformConfig
|
||||||
|
|
||||||
_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {}
|
_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {}
|
||||||
@@ -26,10 +31,12 @@ def get_available_platforms() -> list[Platform]:
|
|||||||
|
|
||||||
def _register_builtin_platforms():
|
def _register_builtin_platforms():
|
||||||
from .daily import DailyClient # noqa: PLC0415
|
from .daily import DailyClient # noqa: PLC0415
|
||||||
|
from .livekit import LiveKitClient # noqa: PLC0415
|
||||||
from .whereby import WherebyClient # noqa: PLC0415
|
from .whereby import WherebyClient # noqa: PLC0415
|
||||||
|
|
||||||
register_platform(WHEREBY_PLATFORM, WherebyClient)
|
register_platform(WHEREBY_PLATFORM, WherebyClient)
|
||||||
register_platform(DAILY_PLATFORM, DailyClient)
|
register_platform(DAILY_PLATFORM, DailyClient)
|
||||||
|
register_platform(LIVEKIT_PLATFORM, LiveKitClient)
|
||||||
|
|
||||||
|
|
||||||
_register_builtin_platforms()
|
_register_builtin_platforms()
|
||||||
|
|||||||
246
server/reflector/views/livekit.py
Normal file
246
server/reflector/views/livekit.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -554,6 +554,7 @@ async def rooms_join_meeting(
|
|||||||
room_name: str,
|
room_name: str,
|
||||||
meeting_id: str,
|
meeting_id: str,
|
||||||
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
|
||||||
|
display_name: str | None = None,
|
||||||
):
|
):
|
||||||
user_id = user["sub"] if user else None
|
user_id = user["sub"] if user else None
|
||||||
room = await rooms_controller.get_by_name(room_name)
|
room = await rooms_controller.get_by_name(room_name)
|
||||||
@@ -598,4 +599,51 @@ async def rooms_join_meeting(
|
|||||||
meeting = meeting.model_copy()
|
meeting = meeting.model_copy()
|
||||||
meeting.room_url = add_query_param(meeting.room_url, "t", token)
|
meeting.room_url = add_query_param(meeting.room_url, "t", token)
|
||||||
|
|
||||||
|
elif meeting.platform == "livekit":
|
||||||
|
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
|
return meeting
|
||||||
|
|||||||
@@ -83,7 +83,25 @@ def build_beat_schedule(
|
|||||||
else:
|
else:
|
||||||
logger.info("Daily.co beat tasks disabled (no DAILY_API_KEY)")
|
logger.info("Daily.co beat tasks disabled (no DAILY_API_KEY)")
|
||||||
|
|
||||||
_any_platform = _whereby_enabled or _daily_enabled
|
_livekit_enabled = bool(settings.LIVEKIT_API_KEY and settings.LIVEKIT_URL)
|
||||||
|
if _livekit_enabled:
|
||||||
|
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:
|
if _any_platform:
|
||||||
beat_schedule["process_meetings"] = {
|
beat_schedule["process_meetings"] = {
|
||||||
"task": "reflector.worker.process.process_meetings",
|
"task": "reflector.worker.process.process_meetings",
|
||||||
|
|||||||
@@ -874,6 +874,22 @@ async def process_meetings():
|
|||||||
logger_.info(
|
logger_.info(
|
||||||
"Meeting deactivated - scheduled time ended with no participants",
|
"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:
|
else:
|
||||||
logger_.debug("Meeting not yet started, keep it")
|
logger_.debug("Meeting not yet started, keep it")
|
||||||
|
|
||||||
@@ -1170,3 +1186,311 @@ async def trigger_daily_reconciliation() -> None:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Reconciliation trigger failed", error=str(e), exc_info=True)
|
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,
|
||||||
|
)
|
||||||
|
|||||||
408
server/tests/test_livekit_backend.py
Normal file
408
server/tests/test_livekit_backend.py
Normal file
@@ -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("<script>alert('xss')</script>")
|
||||||
|
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"
|
||||||
393
server/tests/test_livekit_track_processing.py
Normal file
393
server/tests/test_livekit_track_processing.py
Normal file
@@ -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
|
||||||
40
server/uv.lock
generated
40
server/uv.lock
generated
@@ -1805,6 +1805,35 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "livekit-api"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "aiohttp" },
|
||||||
|
{ name = "livekit-protocol" },
|
||||||
|
{ name = "protobuf" },
|
||||||
|
{ name = "pyjwt" },
|
||||||
|
{ name = "types-protobuf" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/0a/ad3cce124e608c056d6390244ec4dd18c8a4b5f055693a95831da2119af7/livekit_api-1.1.0.tar.gz", hash = "sha256:f94c000534d3a9b506e6aed2f35eb88db1b23bdea33bb322f0144c4e9f73934e", size = 16649, upload-time = "2025-12-02T19:37:11.452Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/b9/8d8515e3e0e629ab07d399cf858b8fc7e0a02bbf6384a6592b285264b4b9/livekit_api-1.1.0-py3-none-any.whl", hash = "sha256:bfc1c2c65392eb3f580a2c28108269f0e79873f053578a677eee7bb1de8aa8fb", size = 19620, upload-time = "2025-12-02T19:37:10.075Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "livekit-protocol"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "protobuf" },
|
||||||
|
{ name = "types-protobuf" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8e/ca/d15e2a2cc8c8aa4ba621fe5f9ffd1806d88ac91c7b8fa4c09a3c0304dd92/livekit_protocol-1.1.3.tar.gz", hash = "sha256:cb4948d2513e81d91583f4a795bf80faa9026cedda509c5714999c7e33564287", size = 88746, upload-time = "2026-03-18T05:25:43.562Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/0e/f3d3e48628294df4559cffd0f8e1adf030127029e5a8da9beff9979090a0/livekit_protocol-1.1.3-py3-none-any.whl", hash = "sha256:fdae5640e064ab6549ec3d62d8bac75a3ef44d7ea73716069b419cbe8b360a5c", size = 107498, upload-time = "2026-03-18T05:25:42.077Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "llama-cloud"
|
name = "llama-cloud"
|
||||||
version = "0.1.35"
|
version = "0.1.35"
|
||||||
@@ -3364,6 +3393,7 @@ dependencies = [
|
|||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "icalendar" },
|
{ name = "icalendar" },
|
||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
|
{ name = "livekit-api" },
|
||||||
{ name = "llama-index" },
|
{ name = "llama-index" },
|
||||||
{ name = "llama-index-llms-openai-like" },
|
{ name = "llama-index-llms-openai-like" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
@@ -3445,6 +3475,7 @@ requires-dist = [
|
|||||||
{ name = "httpx", specifier = ">=0.24.1" },
|
{ name = "httpx", specifier = ">=0.24.1" },
|
||||||
{ name = "icalendar", specifier = ">=6.0.0" },
|
{ name = "icalendar", specifier = ">=6.0.0" },
|
||||||
{ name = "jsonschema", specifier = ">=4.23.0" },
|
{ name = "jsonschema", specifier = ">=4.23.0" },
|
||||||
|
{ name = "livekit-api", specifier = ">=1.1.0" },
|
||||||
{ name = "llama-index", specifier = ">=0.12.52" },
|
{ name = "llama-index", specifier = ">=0.12.52" },
|
||||||
{ name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },
|
{ name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },
|
||||||
{ name = "openai", specifier = ">=1.59.7" },
|
{ name = "openai", specifier = ">=1.59.7" },
|
||||||
@@ -4399,6 +4430,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-protobuf"
|
||||||
|
version = "6.32.1.20260221"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.14.1"
|
version = "4.14.1"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const recordingTypeOptions: SelectOption[] = [
|
|||||||
const platformOptions: SelectOption[] = [
|
const platformOptions: SelectOption[] = [
|
||||||
{ label: "Whereby", value: "whereby" },
|
{ label: "Whereby", value: "whereby" },
|
||||||
{ label: "Daily", value: "daily" },
|
{ label: "Daily", value: "daily" },
|
||||||
|
{ label: "LiveKit", value: "livekit" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const roomInitialState = {
|
const roomInitialState = {
|
||||||
@@ -309,10 +310,7 @@ export default function RoomsList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform: "whereby" | "daily" =
|
const platform = room.platform as "whereby" | "daily" | "livekit";
|
||||||
room.platform === "whereby" || room.platform === "daily"
|
|
||||||
? room.platform
|
|
||||||
: "daily";
|
|
||||||
|
|
||||||
const roomData = {
|
const roomData = {
|
||||||
name: room.name,
|
name: room.name,
|
||||||
@@ -544,7 +542,10 @@ export default function RoomsList() {
|
|||||||
<Select.Root
|
<Select.Root
|
||||||
value={[room.platform]}
|
value={[room.platform]}
|
||||||
onValueChange={(e) => {
|
onValueChange={(e) => {
|
||||||
const newPlatform = e.value[0] as "whereby" | "daily";
|
const newPlatform = e.value[0] as
|
||||||
|
| "whereby"
|
||||||
|
| "daily"
|
||||||
|
| "livekit";
|
||||||
const updates: Partial<typeof room> = {
|
const updates: Partial<typeof room> = {
|
||||||
platform: newPlatform,
|
platform: newPlatform,
|
||||||
};
|
};
|
||||||
|
|||||||
277
www/app/[roomName]/components/LiveKitRoom.tsx
Normal file
277
www/app/[roomName]/components/LiveKitRoom.tsx
Normal file
@@ -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=<name>&token=<jwt>
|
||||||
|
* We split these for the LiveKit React SDK.
|
||||||
|
*/
|
||||||
|
function parseLiveKitUrl(roomUrl: string): {
|
||||||
|
serverUrl: string;
|
||||||
|
roomName: string | null;
|
||||||
|
token: string | null;
|
||||||
|
} {
|
||||||
|
try {
|
||||||
|
const url = new URL(roomUrl);
|
||||||
|
const token = url.searchParams.get("token");
|
||||||
|
const roomName = url.searchParams.get("room");
|
||||||
|
url.searchParams.delete("token");
|
||||||
|
url.searchParams.delete("room");
|
||||||
|
// Strip trailing slash and leftover ? from URL API
|
||||||
|
const serverUrl = url.toString().replace(/[?/]+$/, "");
|
||||||
|
return { serverUrl, roomName, token };
|
||||||
|
} catch {
|
||||||
|
return { serverUrl: roomUrl, roomName: null, token: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveKitRoom({ meeting, room }: LiveKitRoomProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const auth = useAuth();
|
||||||
|
const authLastUserId = auth.lastUserId;
|
||||||
|
const roomName = params?.roomName as string;
|
||||||
|
const meetingId = assertMeetingId(meeting.id);
|
||||||
|
|
||||||
|
const joinMutation = useRoomJoinMeeting();
|
||||||
|
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(null);
|
||||||
|
const [connectionError, setConnectionError] = useState(false);
|
||||||
|
const [userChoices, setUserChoices] = useState<LocalUserChoices | null>(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 (
|
||||||
|
<Box
|
||||||
|
w="100vw"
|
||||||
|
h="100vh"
|
||||||
|
display="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
bg="gray.900"
|
||||||
|
data-lk-theme="default"
|
||||||
|
>
|
||||||
|
<PreJoin
|
||||||
|
defaults={{
|
||||||
|
username: defaultUsername,
|
||||||
|
audioEnabled: true,
|
||||||
|
videoEnabled: true,
|
||||||
|
}}
|
||||||
|
onSubmit={handlePreJoinSubmit}
|
||||||
|
userLabel="Display Name"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading / error states ────────────────────────────────
|
||||||
|
if (isJoining) {
|
||||||
|
return (
|
||||||
|
<Center h="100vh" bg="gray.900">
|
||||||
|
<Spinner color="blue.500" size="xl" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionError) {
|
||||||
|
return (
|
||||||
|
<Center h="100vh" bg="gray.50">
|
||||||
|
<Text fontSize="lg">Failed to connect to meeting</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!joinedMeeting) {
|
||||||
|
return (
|
||||||
|
<Center h="100vh" bg="gray.50">
|
||||||
|
<Spinner color="blue.500" size="xl" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
serverUrl,
|
||||||
|
roomName: lkRoomName,
|
||||||
|
token,
|
||||||
|
} = parseLiveKitUrl(joinedMeeting.room_url);
|
||||||
|
|
||||||
|
if (
|
||||||
|
serverUrl &&
|
||||||
|
!serverUrl.startsWith("ws://") &&
|
||||||
|
!serverUrl.startsWith("wss://")
|
||||||
|
) {
|
||||||
|
console.warn(
|
||||||
|
`LiveKit serverUrl has unexpected scheme: ${serverUrl}. Expected ws:// or wss://`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token || !lkRoomName) {
|
||||||
|
return (
|
||||||
|
<Center h="100vh" bg="gray.50">
|
||||||
|
<Text fontSize="lg">
|
||||||
|
{!token
|
||||||
|
? "No access token received from server"
|
||||||
|
: "No room name received from server"}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────
|
||||||
|
// The token already encodes the room name (in VideoGrants.room),
|
||||||
|
// so LiveKit SDK joins the correct room from the token alone.
|
||||||
|
return (
|
||||||
|
<Box w="100vw" h="100vh" bg="black" position="relative">
|
||||||
|
<LKRoom
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
token={token}
|
||||||
|
connect={true}
|
||||||
|
audio={userChoices.audioEnabled}
|
||||||
|
video={userChoices.videoEnabled}
|
||||||
|
onDisconnected={handleDisconnected}
|
||||||
|
data-lk-theme="default"
|
||||||
|
style={{ height: "100%" }}
|
||||||
|
>
|
||||||
|
<VideoConference />
|
||||||
|
<RoomAudioRenderer />
|
||||||
|
</LKRoom>
|
||||||
|
|
||||||
|
{/* ── Floating overlay buttons (consent, email, extensible) ── */}
|
||||||
|
{showConsentButton && (
|
||||||
|
<ConsentDialogButton
|
||||||
|
meetingId={meetingId}
|
||||||
|
recordingType={meeting.recording_type}
|
||||||
|
skipConsent={room.skip_consent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRecordingIndicator && <RecordingIndicator />}
|
||||||
|
|
||||||
|
{showEmailFeature && (
|
||||||
|
<IconButton
|
||||||
|
aria-label="Email transcript"
|
||||||
|
position="absolute"
|
||||||
|
top="56px"
|
||||||
|
right="8px"
|
||||||
|
zIndex={1000}
|
||||||
|
colorPalette="blue"
|
||||||
|
size="sm"
|
||||||
|
onClick={showEmailModal}
|
||||||
|
variant="solid"
|
||||||
|
borderRadius="full"
|
||||||
|
>
|
||||||
|
<LuMail />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import MeetingSelection from "../MeetingSelection";
|
|||||||
import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
|
import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
|
||||||
import WherebyRoom from "./WherebyRoom";
|
import WherebyRoom from "./WherebyRoom";
|
||||||
import DailyRoom from "./DailyRoom";
|
import DailyRoom from "./DailyRoom";
|
||||||
|
import LiveKitRoom from "./LiveKitRoom";
|
||||||
import { useAuth } from "../../lib/AuthProvider";
|
import { useAuth } from "../../lib/AuthProvider";
|
||||||
import { useError } from "../../(errors)/errorContext";
|
import { useError } from "../../(errors)/errorContext";
|
||||||
import { parseNonEmptyString } from "../../lib/utils";
|
import { parseNonEmptyString } from "../../lib/utils";
|
||||||
@@ -199,8 +200,9 @@ export default function RoomContainer(details: RoomDetails) {
|
|||||||
return <DailyRoom meeting={meeting} room={room} />;
|
return <DailyRoom meeting={meeting} room={room} />;
|
||||||
case "whereby":
|
case "whereby":
|
||||||
return <WherebyRoom meeting={meeting} room={room} />;
|
return <WherebyRoom meeting={meeting} room={room} />;
|
||||||
default: {
|
case "livekit":
|
||||||
const _exhaustive: never = platform;
|
return <LiveKitRoom meeting={meeting} room={room} />;
|
||||||
|
default:
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
display="flex"
|
display="flex"
|
||||||
@@ -213,6 +215,5 @@ export default function RoomContainer(details: RoomDetails) {
|
|||||||
<Text fontSize="lg">Unknown platform: {platform}</Text>
|
<Text fontSize="lg">Unknown platform: {platform}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "./styles/globals.scss";
|
import "./styles/globals.scss";
|
||||||
|
import "@livekit/components-styles";
|
||||||
import { Metadata, Viewport } from "next";
|
import { Metadata, Viewport } from "next";
|
||||||
import { Poppins } from "next/font/google";
|
import { Poppins } from "next/font/google";
|
||||||
import { ErrorProvider } from "./(errors)/errorContext";
|
import { ErrorProvider } from "./(errors)/errorContext";
|
||||||
|
|||||||
60
www/app/reflector-api.d.ts
vendored
60
www/app/reflector-api.d.ts
vendored
@@ -911,6 +911,32 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/v1/livekit/webhook": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Livekit Webhook
|
||||||
|
* @description Handle LiveKit webhook events.
|
||||||
|
*
|
||||||
|
* LiveKit webhook events include:
|
||||||
|
* - participant_joined / participant_left
|
||||||
|
* - egress_started / egress_updated / egress_ended
|
||||||
|
* - room_started / room_finished
|
||||||
|
* - track_published / track_unpublished
|
||||||
|
*/
|
||||||
|
post: operations["v1_livekit_webhook"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/v1/auth/login": {
|
"/v1/auth/login": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1100,7 +1126,7 @@ export interface components {
|
|||||||
* Platform
|
* Platform
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
platform: "whereby" | "daily";
|
platform: "whereby" | "daily" | "livekit";
|
||||||
/**
|
/**
|
||||||
* Skip Consent
|
* Skip Consent
|
||||||
* @default false
|
* @default false
|
||||||
@@ -1821,7 +1847,7 @@ export interface components {
|
|||||||
* Platform
|
* Platform
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
platform: "whereby" | "daily";
|
platform: "whereby" | "daily" | "livekit";
|
||||||
/** Daily Composed Video S3 Key */
|
/** Daily Composed Video S3 Key */
|
||||||
daily_composed_video_s3_key?: string | null;
|
daily_composed_video_s3_key?: string | null;
|
||||||
/** Daily Composed Video Duration */
|
/** Daily Composed Video Duration */
|
||||||
@@ -1921,7 +1947,7 @@ export interface components {
|
|||||||
* Platform
|
* Platform
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
platform: "whereby" | "daily";
|
platform: "whereby" | "daily" | "livekit";
|
||||||
/**
|
/**
|
||||||
* Skip Consent
|
* Skip Consent
|
||||||
* @default false
|
* @default false
|
||||||
@@ -1979,7 +2005,7 @@ export interface components {
|
|||||||
* Platform
|
* Platform
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
platform: "whereby" | "daily";
|
platform: "whereby" | "daily" | "livekit";
|
||||||
/**
|
/**
|
||||||
* Skip Consent
|
* Skip Consent
|
||||||
* @default false
|
* @default false
|
||||||
@@ -2358,7 +2384,7 @@ export interface components {
|
|||||||
/** Ics Enabled */
|
/** Ics Enabled */
|
||||||
ics_enabled?: boolean | null;
|
ics_enabled?: boolean | null;
|
||||||
/** Platform */
|
/** Platform */
|
||||||
platform?: ("whereby" | "daily") | null;
|
platform?: ("whereby" | "daily" | "livekit") | null;
|
||||||
/** Skip Consent */
|
/** Skip Consent */
|
||||||
skip_consent?: boolean | null;
|
skip_consent?: boolean | null;
|
||||||
/** Email Transcript To */
|
/** Email Transcript To */
|
||||||
@@ -3233,7 +3259,9 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
v1_rooms_join_meeting: {
|
v1_rooms_join_meeting: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: {
|
||||||
|
display_name?: string | null;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
room_name: string;
|
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: {
|
v1_login: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^3.2.0",
|
"@fortawesome/react-fontawesome": "^3.2.0",
|
||||||
|
"@livekit/components-react": "2.9.20",
|
||||||
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@sentry/nextjs": "^10.40.0",
|
"@sentry/nextjs": "^10.40.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"@whereby.com/browser-sdk": "^3.18.21",
|
"@whereby.com/browser-sdk": "^3.18.21",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"fontawesome": "^5.6.3",
|
"fontawesome": "^5.6.3",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
"jest-worker": "^30.2.0",
|
"jest-worker": "^30.2.0",
|
||||||
|
"livekit-client": "2.18.0",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"next": "16.1.7",
|
"next": "16.1.7",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
|
|||||||
150
www/pnpm-lock.yaml
generated
150
www/pnpm-lock.yaml
generated
@@ -34,6 +34,12 @@ importers:
|
|||||||
'@fortawesome/react-fontawesome':
|
'@fortawesome/react-fontawesome':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.2.0(@fortawesome/fontawesome-svg-core@7.2.0)(react@19.2.4)
|
version: 3.2.0(@fortawesome/fontawesome-svg-core@7.2.0)(react@19.2.4)
|
||||||
|
'@livekit/components-react':
|
||||||
|
specifier: 2.9.20
|
||||||
|
version: 2.9.20(livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tslib@2.8.1)
|
||||||
|
'@livekit/components-styles':
|
||||||
|
specifier: 1.2.0
|
||||||
|
version: 1.2.0
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: ^10.40.0
|
specifier: ^10.40.0
|
||||||
version: 10.40.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)(webpack@5.105.3)
|
version: 10.40.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sass@1.97.3))(react@19.2.4)(webpack@5.105.3)
|
||||||
@@ -64,6 +70,9 @@ importers:
|
|||||||
jest-worker:
|
jest-worker:
|
||||||
specifier: ^30.2.0
|
specifier: ^30.2.0
|
||||||
version: 30.2.0
|
version: 30.2.0
|
||||||
|
livekit-client:
|
||||||
|
specifier: 2.18.0
|
||||||
|
version: 2.18.0(@types/dom-mediacapture-record@1.0.22)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.575.0
|
specifier: ^0.575.0
|
||||||
version: 0.575.0(react@19.2.4)
|
version: 0.575.0(react@19.2.4)
|
||||||
@@ -343,6 +352,9 @@ packages:
|
|||||||
'@bcoe/v8-coverage@0.2.3':
|
'@bcoe/v8-coverage@0.2.3':
|
||||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||||
|
|
||||||
|
'@bufbuild/protobuf@1.10.1':
|
||||||
|
resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==}
|
||||||
|
|
||||||
'@chakra-ui/react@3.33.0':
|
'@chakra-ui/react@3.33.0':
|
||||||
resolution: {integrity: sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==}
|
resolution: {integrity: sha512-HNbUFsFABjVL5IHBxsqtuT+AH/vQT1+xsEWrxnG0GBM2VjlzlMqlqCxNiDyQOsjLZXQC1ciCMbzPNcSCc63Y9w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -445,6 +457,9 @@ packages:
|
|||||||
'@floating-ui/core@1.7.4':
|
'@floating-ui/core@1.7.4':
|
||||||
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||||
|
|
||||||
'@floating-ui/dom@1.7.5':
|
'@floating-ui/dom@1.7.5':
|
||||||
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
|
resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==}
|
||||||
|
|
||||||
@@ -767,6 +782,36 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@livekit/components-core@0.12.13':
|
||||||
|
resolution: {integrity: sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
livekit-client: ^2.17.2
|
||||||
|
tslib: ^2.6.2
|
||||||
|
|
||||||
|
'@livekit/components-react@2.9.20':
|
||||||
|
resolution: {integrity: sha512-hjkYOsJj9Jbghb7wM5cI8HoVisKeL6Zcy1VnRWTLm0sqVbto8GJp/17T4Udx85mCPY6Jgh8I1Cv0yVzgz7CQtg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
peerDependencies:
|
||||||
|
'@livekit/krisp-noise-filter': ^0.2.12 || ^0.3.0
|
||||||
|
livekit-client: ^2.17.2
|
||||||
|
react: '>=18'
|
||||||
|
react-dom: '>=18'
|
||||||
|
tslib: ^2.6.2
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@livekit/krisp-noise-filter':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@livekit/components-styles@1.2.0':
|
||||||
|
resolution: {integrity: sha512-74/rt0lDh6aHmOPmWAeDE9C4OrNW9RIdmhX/YRbovQBVNGNVWojRjl3FgQZ5LPFXO6l1maKB4JhXcBFENVxVvw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
'@livekit/mutex@1.1.1':
|
||||||
|
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
|
||||||
|
|
||||||
|
'@livekit/protocol@1.44.0':
|
||||||
|
resolution: {integrity: sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==}
|
||||||
|
|
||||||
'@lukeed/csprng@1.1.0':
|
'@lukeed/csprng@1.1.0':
|
||||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1886,6 +1931,9 @@ packages:
|
|||||||
'@types/debug@4.1.12':
|
'@types/debug@4.1.12':
|
||||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||||
|
|
||||||
|
'@types/dom-mediacapture-record@1.0.22':
|
||||||
|
resolution: {integrity: sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==}
|
||||||
|
|
||||||
@@ -3839,6 +3887,9 @@ packages:
|
|||||||
jose@4.15.9:
|
jose@4.15.9:
|
||||||
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
|
||||||
|
|
||||||
|
jose@6.2.2:
|
||||||
|
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
|
||||||
|
|
||||||
js-levenshtein@1.1.6:
|
js-levenshtein@1.1.6:
|
||||||
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3984,6 +4035,11 @@ packages:
|
|||||||
lines-and-columns@1.2.4:
|
lines-and-columns@1.2.4:
|
||||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||||
|
|
||||||
|
livekit-client@2.18.0:
|
||||||
|
resolution: {integrity: sha512-wjH4y0rw5fnkPmmaxutPhD4XcAq6goQszS8lw9PEpGXVwiRE6sI/ZH+mOT/s8AHJnEC3tjmfiMZ4MQt8BlaWew==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/dom-mediacapture-record': ^1
|
||||||
|
|
||||||
loader-runner@4.3.1:
|
loader-runner@4.3.1:
|
||||||
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
|
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
|
||||||
engines: {node: '>=6.11.5'}
|
engines: {node: '>=6.11.5'}
|
||||||
@@ -3996,6 +4052,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
lodash.debounce@4.0.8:
|
||||||
|
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||||
|
|
||||||
lodash.defaults@4.2.0:
|
lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
@@ -4005,6 +4064,14 @@ packages:
|
|||||||
lodash.memoize@4.1.2:
|
lodash.memoize@4.1.2:
|
||||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||||
|
|
||||||
|
loglevel@1.9.1:
|
||||||
|
resolution: {integrity: sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
|
loglevel@1.9.2:
|
||||||
|
resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
longest-streak@3.1.0:
|
longest-streak@3.1.0:
|
||||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||||
|
|
||||||
@@ -4752,6 +4819,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-K6p9y4ZyL9wPzA+PMDloNQPfoDGTiFYDvdlXznyGKgD10BJpcAosvATKrExRKOrNLgD8E7Um7WGW0lxsnOuNLg==}
|
resolution: {integrity: sha512-K6p9y4ZyL9wPzA+PMDloNQPfoDGTiFYDvdlXznyGKgD10BJpcAosvATKrExRKOrNLgD8E7Um7WGW0lxsnOuNLg==}
|
||||||
engines: {node: '>=4.0.0'}
|
engines: {node: '>=4.0.0'}
|
||||||
|
|
||||||
|
rxjs@7.8.2:
|
||||||
|
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
|
||||||
|
|
||||||
safe-array-concat@1.1.3:
|
safe-array-concat@1.1.3:
|
||||||
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@@ -5133,6 +5203,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
typed-emitter@2.1.0:
|
||||||
|
resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==}
|
||||||
|
|
||||||
typescript-eslint@8.56.1:
|
typescript-eslint@8.56.1:
|
||||||
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
|
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -5228,6 +5301,12 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
usehooks-ts@3.1.1:
|
||||||
|
resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==}
|
||||||
|
engines: {node: '>=16.15.0'}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
util-deprecate@1.0.2:
|
util-deprecate@1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
@@ -5662,6 +5741,8 @@ snapshots:
|
|||||||
|
|
||||||
'@bcoe/v8-coverage@0.2.3': {}
|
'@bcoe/v8-coverage@0.2.3': {}
|
||||||
|
|
||||||
|
'@bufbuild/protobuf@1.10.1': {}
|
||||||
|
|
||||||
'@chakra-ui/react@3.33.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
'@chakra-ui/react@3.33.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ark-ui/react': 5.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
'@ark-ui/react': 5.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -5811,6 +5892,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.10
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
|
'@floating-ui/dom@1.7.4':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/core': 1.7.4
|
||||||
|
'@floating-ui/utils': 0.2.10
|
||||||
|
|
||||||
'@floating-ui/dom@1.7.5':
|
'@floating-ui/dom@1.7.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/core': 1.7.4
|
'@floating-ui/core': 1.7.4
|
||||||
@@ -6179,6 +6265,34 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@livekit/components-core@0.12.13(livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||||
|
dependencies:
|
||||||
|
'@floating-ui/dom': 1.7.4
|
||||||
|
livekit-client: 2.18.0(@types/dom-mediacapture-record@1.0.22)
|
||||||
|
loglevel: 1.9.1
|
||||||
|
rxjs: 7.8.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
'@livekit/components-react@2.9.20(livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tslib@2.8.1)':
|
||||||
|
dependencies:
|
||||||
|
'@livekit/components-core': 0.12.13(livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||||
|
clsx: 2.1.1
|
||||||
|
events: 3.3.0
|
||||||
|
jose: 6.2.2
|
||||||
|
livekit-client: 2.18.0(@types/dom-mediacapture-record@1.0.22)
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
tslib: 2.8.1
|
||||||
|
usehooks-ts: 3.1.1(react@19.2.4)
|
||||||
|
|
||||||
|
'@livekit/components-styles@1.2.0': {}
|
||||||
|
|
||||||
|
'@livekit/mutex@1.1.1': {}
|
||||||
|
|
||||||
|
'@livekit/protocol@1.44.0':
|
||||||
|
dependencies:
|
||||||
|
'@bufbuild/protobuf': 1.10.1
|
||||||
|
|
||||||
'@lukeed/csprng@1.1.0': {}
|
'@lukeed/csprng@1.1.0': {}
|
||||||
|
|
||||||
'@lukeed/uuid@2.0.1':
|
'@lukeed/uuid@2.0.1':
|
||||||
@@ -7259,6 +7373,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
|
|
||||||
|
'@types/dom-mediacapture-record@1.0.22': {}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/eslint': 9.6.1
|
'@types/eslint': 9.6.1
|
||||||
@@ -9986,6 +10102,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@4.15.9: {}
|
jose@4.15.9: {}
|
||||||
|
|
||||||
|
jose@6.2.2: {}
|
||||||
|
|
||||||
js-levenshtein@1.1.6: {}
|
js-levenshtein@1.1.6: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
@@ -10101,6 +10219,19 @@ snapshots:
|
|||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
|
|
||||||
|
livekit-client@2.18.0(@types/dom-mediacapture-record@1.0.22):
|
||||||
|
dependencies:
|
||||||
|
'@livekit/mutex': 1.1.1
|
||||||
|
'@livekit/protocol': 1.44.0
|
||||||
|
'@types/dom-mediacapture-record': 1.0.22
|
||||||
|
events: 3.3.0
|
||||||
|
jose: 6.2.2
|
||||||
|
loglevel: 1.9.2
|
||||||
|
sdp-transform: 2.15.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
typed-emitter: 2.1.0
|
||||||
|
webrtc-adapter: 9.0.4
|
||||||
|
|
||||||
loader-runner@4.3.1: {}
|
loader-runner@4.3.1: {}
|
||||||
|
|
||||||
locate-path@5.0.0:
|
locate-path@5.0.0:
|
||||||
@@ -10111,12 +10242,18 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
|
|
||||||
|
lodash.debounce@4.0.8: {}
|
||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0: {}
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
lodash.memoize@4.1.2: {}
|
lodash.memoize@4.1.2: {}
|
||||||
|
|
||||||
|
loglevel@1.9.1: {}
|
||||||
|
|
||||||
|
loglevel@1.9.2: {}
|
||||||
|
|
||||||
longest-streak@3.1.0: {}
|
longest-streak@3.1.0: {}
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
@@ -11009,6 +11146,10 @@ snapshots:
|
|||||||
|
|
||||||
runes@0.4.3: {}
|
runes@0.4.3: {}
|
||||||
|
|
||||||
|
rxjs@7.8.2:
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
|
||||||
safe-array-concat@1.1.3:
|
safe-array-concat@1.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.8
|
call-bind: 1.0.8
|
||||||
@@ -11462,6 +11603,10 @@ snapshots:
|
|||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
reflect.getprototypeof: 1.0.10
|
reflect.getprototypeof: 1.0.10
|
||||||
|
|
||||||
|
typed-emitter@2.1.0:
|
||||||
|
optionalDependencies:
|
||||||
|
rxjs: 7.8.2
|
||||||
|
|
||||||
typescript-eslint@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3):
|
typescript-eslint@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)
|
||||||
@@ -11585,6 +11730,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
usehooks-ts@3.1.1(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
lodash.debounce: 4.0.8
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
util-deprecate@1.0.2: {}
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
uuid-validate@0.0.3: {}
|
uuid-validate@0.0.3: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user