mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-04-02 12:16:47 +00:00
feat: Livekit bare no recording nor pipeline
This commit is contained in:
@@ -42,6 +42,7 @@ dependencies = [
|
||||
"pydantic>=2.12.5",
|
||||
"aiosmtplib>=3.0.0",
|
||||
"email-validator>=2.0.0",
|
||||
"livekit-api>=1.1.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -15,6 +15,7 @@ from reflector.metrics import metrics_init
|
||||
from reflector.settings import settings
|
||||
from reflector.views.config import router as config_router
|
||||
from reflector.views.daily import router as daily_router
|
||||
from reflector.views.livekit import router as livekit_router
|
||||
from reflector.views.meetings import router as meetings_router
|
||||
from reflector.views.rooms import router as rooms_router
|
||||
from reflector.views.rtc_offer import router as rtc_offer_router
|
||||
@@ -112,6 +113,7 @@ app.include_router(config_router, prefix="/v1")
|
||||
app.include_router(zulip_router, prefix="/v1")
|
||||
app.include_router(whereby_router, prefix="/v1")
|
||||
app.include_router(daily_router, prefix="/v1/daily")
|
||||
app.include_router(livekit_router, prefix="/v1/livekit")
|
||||
if auth_router:
|
||||
app.include_router(auth_router, prefix="/v1")
|
||||
add_pagination(app)
|
||||
|
||||
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",
|
||||
]
|
||||
177
server/reflector/livekit_api/client.py
Normal file
177
server/reflector/livekit_api/client.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
LiveKit API client wrapping the official livekit-api Python SDK.
|
||||
|
||||
Handles room management, access tokens, and Track Egress for
|
||||
per-participant audio recording to S3-compatible storage.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from livekit.api import (
|
||||
AccessToken,
|
||||
CreateRoomRequest,
|
||||
DeleteRoomRequest,
|
||||
DirectFileOutput,
|
||||
EgressInfo,
|
||||
ListEgressRequest,
|
||||
ListParticipantsRequest,
|
||||
LiveKitAPI,
|
||||
Room,
|
||||
S3Upload,
|
||||
StopEgressRequest,
|
||||
TrackEgressRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
|
||||
class LiveKitApiClient:
|
||||
"""Thin wrapper around LiveKitAPI for Reflector's needs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str,
|
||||
api_key: str,
|
||||
api_secret: str,
|
||||
s3_bucket: str | None = None,
|
||||
s3_region: str | None = None,
|
||||
s3_access_key: str | None = None,
|
||||
s3_secret_key: str | None = None,
|
||||
s3_endpoint: str | None = None,
|
||||
):
|
||||
self._url = url
|
||||
self._api_key = api_key
|
||||
self._api_secret = api_secret
|
||||
self._s3_bucket = s3_bucket
|
||||
self._s3_region = s3_region or "us-east-1"
|
||||
self._s3_access_key = s3_access_key
|
||||
self._s3_secret_key = s3_secret_key
|
||||
self._s3_endpoint = s3_endpoint
|
||||
self._api = LiveKitAPI(url=url, api_key=api_key, api_secret=api_secret)
|
||||
|
||||
# ── Room management ──────────────────────────────────────────
|
||||
|
||||
async def create_room(
|
||||
self,
|
||||
name: str,
|
||||
empty_timeout: int = 300,
|
||||
max_participants: int = 0,
|
||||
) -> Room:
|
||||
"""Create a LiveKit room.
|
||||
|
||||
Args:
|
||||
name: Room name (unique identifier).
|
||||
empty_timeout: Seconds to keep room alive after last participant leaves.
|
||||
max_participants: 0 = unlimited.
|
||||
"""
|
||||
req = CreateRoomRequest(
|
||||
name=name,
|
||||
empty_timeout=empty_timeout,
|
||||
max_participants=max_participants,
|
||||
)
|
||||
return await self._api.room.create_room(req)
|
||||
|
||||
async def delete_room(self, room_name: str) -> None:
|
||||
await self._api.room.delete_room(DeleteRoomRequest(room=room_name))
|
||||
|
||||
async def list_participants(self, room_name: str):
|
||||
resp = await self._api.room.list_participants(
|
||||
ListParticipantsRequest(room=room_name)
|
||||
)
|
||||
return resp.participants
|
||||
|
||||
# ── Access tokens ────────────────────────────────────────────
|
||||
|
||||
def create_access_token(
|
||||
self,
|
||||
room_name: str,
|
||||
participant_identity: str,
|
||||
participant_name: str | None = None,
|
||||
can_publish: bool = True,
|
||||
can_subscribe: bool = True,
|
||||
room_admin: bool = False,
|
||||
ttl_seconds: int = 86400,
|
||||
) -> str:
|
||||
"""Generate a JWT access token for a participant."""
|
||||
token = AccessToken(
|
||||
api_key=self._api_key,
|
||||
api_secret=self._api_secret,
|
||||
)
|
||||
token.identity = participant_identity
|
||||
token.name = participant_name or participant_identity
|
||||
token.ttl = timedelta(seconds=ttl_seconds)
|
||||
token.with_grants(
|
||||
VideoGrants(
|
||||
room_join=True,
|
||||
room=room_name,
|
||||
can_publish=can_publish,
|
||||
can_subscribe=can_subscribe,
|
||||
room_admin=room_admin,
|
||||
)
|
||||
)
|
||||
return token.to_jwt()
|
||||
|
||||
# ── Track Egress (per-participant audio recording) ───────────
|
||||
|
||||
def _build_s3_upload(self) -> S3Upload:
|
||||
"""Build S3Upload config for egress output."""
|
||||
if not all([self._s3_bucket, self._s3_access_key, self._s3_secret_key]):
|
||||
raise ValueError(
|
||||
"S3 storage not configured for LiveKit egress. "
|
||||
"Set LIVEKIT_STORAGE_AWS_* environment variables."
|
||||
)
|
||||
kwargs = {
|
||||
"access_key": self._s3_access_key,
|
||||
"secret": self._s3_secret_key,
|
||||
"bucket": self._s3_bucket,
|
||||
"region": self._s3_region,
|
||||
"force_path_style": True, # Required for Garage/MinIO
|
||||
}
|
||||
if self._s3_endpoint:
|
||||
kwargs["endpoint"] = self._s3_endpoint
|
||||
return S3Upload(**kwargs)
|
||||
|
||||
async def start_track_egress(
|
||||
self,
|
||||
room_name: str,
|
||||
track_sid: str,
|
||||
s3_filepath: str,
|
||||
) -> EgressInfo:
|
||||
"""Start Track Egress for a single audio track (writes OGG/Opus to S3).
|
||||
|
||||
Args:
|
||||
room_name: LiveKit room name.
|
||||
track_sid: Track SID to record.
|
||||
s3_filepath: S3 key path for the output file.
|
||||
"""
|
||||
req = TrackEgressRequest(
|
||||
room_name=room_name,
|
||||
track_id=track_sid,
|
||||
file=DirectFileOutput(
|
||||
filepath=s3_filepath,
|
||||
s3=self._build_s3_upload(),
|
||||
),
|
||||
)
|
||||
return await self._api.egress.start_track_egress(req)
|
||||
|
||||
async def list_egress(self, room_name: str | None = None) -> list[EgressInfo]:
|
||||
req = ListEgressRequest()
|
||||
if room_name:
|
||||
req.room_name = room_name
|
||||
resp = await self._api.egress.list_egress(req)
|
||||
return list(resp.items)
|
||||
|
||||
async def stop_egress(self, egress_id: str) -> EgressInfo:
|
||||
return await self._api.egress.stop_egress(
|
||||
StopEgressRequest(egress_id=egress_id)
|
||||
)
|
||||
|
||||
# ── Cleanup ──────────────────────────────────────────────────
|
||||
|
||||
async def close(self):
|
||||
await self._api.aclose()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
35
server/reflector/livekit_api/webhooks.py
Normal file
35
server/reflector/livekit_api/webhooks.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
LiveKit webhook verification and event parsing.
|
||||
|
||||
LiveKit signs webhooks using the API secret as a JWT.
|
||||
The WebhookReceiver from the SDK handles verification.
|
||||
"""
|
||||
|
||||
from livekit.api import TokenVerifier, WebhookEvent, WebhookReceiver
|
||||
|
||||
from reflector.logger import logger
|
||||
|
||||
|
||||
def create_webhook_receiver(api_key: str, api_secret: str) -> WebhookReceiver:
|
||||
"""Create a WebhookReceiver for verifying LiveKit webhook signatures."""
|
||||
return WebhookReceiver(
|
||||
token_verifier=TokenVerifier(api_key=api_key, api_secret=api_secret)
|
||||
)
|
||||
|
||||
|
||||
def verify_webhook(
|
||||
receiver: WebhookReceiver,
|
||||
body: str | bytes,
|
||||
auth_header: str,
|
||||
) -> WebhookEvent | None:
|
||||
"""Verify and parse a LiveKit webhook event.
|
||||
|
||||
Returns the parsed WebhookEvent if valid, None if verification fails.
|
||||
"""
|
||||
if isinstance(body, bytes):
|
||||
body = body.decode("utf-8")
|
||||
try:
|
||||
return receiver.receive(body, auth_header)
|
||||
except Exception as e:
|
||||
logger.warning("LiveKit webhook verification failed", error=str(e))
|
||||
return None
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Literal
|
||||
|
||||
Platform = Literal["whereby", "daily"]
|
||||
Platform = Literal["whereby", "daily", "livekit"]
|
||||
WHEREBY_PLATFORM: Platform = "whereby"
|
||||
DAILY_PLATFORM: Platform = "daily"
|
||||
LIVEKIT_PLATFORM: Platform = "livekit"
|
||||
|
||||
@@ -195,6 +195,23 @@ class Settings(BaseSettings):
|
||||
DAILY_WEBHOOK_UUID: str | None = (
|
||||
None # Webhook UUID for this environment. Not used by production code
|
||||
)
|
||||
|
||||
# LiveKit integration (self-hosted open-source video platform)
|
||||
LIVEKIT_URL: str | None = (
|
||||
None # e.g. ws://livekit:7880 (internal) or wss://livekit.example.com
|
||||
)
|
||||
LIVEKIT_API_KEY: str | None = None
|
||||
LIVEKIT_API_SECRET: str | None = None
|
||||
LIVEKIT_WEBHOOK_SECRET: str | None = None # Defaults to API_SECRET if not set
|
||||
# LiveKit egress S3 storage (Track Egress writes per-participant audio here)
|
||||
LIVEKIT_STORAGE_AWS_BUCKET_NAME: str | None = None
|
||||
LIVEKIT_STORAGE_AWS_REGION: str | None = None
|
||||
LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
|
||||
LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
|
||||
LIVEKIT_STORAGE_AWS_ENDPOINT_URL: str | None = None # For Garage/MinIO
|
||||
# Public URL for LiveKit (used in frontend room_url, e.g. wss://livekit.example.com)
|
||||
LIVEKIT_PUBLIC_URL: str | None = None
|
||||
|
||||
# Platform Configuration
|
||||
DEFAULT_VIDEO_PLATFORM: Platform = DAILY_PLATFORM
|
||||
|
||||
|
||||
@@ -57,6 +57,22 @@ def get_source_storage(platform: str) -> Storage:
|
||||
aws_secret_access_key=settings.WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
|
||||
elif platform == "livekit":
|
||||
if (
|
||||
settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID
|
||||
and settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY
|
||||
and settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME
|
||||
):
|
||||
from reflector.storage.storage_aws import AwsStorage
|
||||
|
||||
return AwsStorage(
|
||||
aws_bucket_name=settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME,
|
||||
aws_region=settings.LIVEKIT_STORAGE_AWS_REGION or "us-east-1",
|
||||
aws_access_key_id=settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY,
|
||||
aws_endpoint_url=settings.LIVEKIT_STORAGE_AWS_ENDPOINT_URL,
|
||||
)
|
||||
|
||||
return get_transcripts_storage()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from reflector.settings import settings
|
||||
from reflector.storage import get_dailyco_storage, get_whereby_storage
|
||||
|
||||
from ..schemas.platform import WHEREBY_PLATFORM, Platform
|
||||
from ..schemas.platform import LIVEKIT_PLATFORM, WHEREBY_PLATFORM, Platform
|
||||
from .base import VideoPlatformClient, VideoPlatformConfig
|
||||
from .registry import get_platform_client
|
||||
|
||||
@@ -44,6 +44,27 @@ def get_platform_config(platform: Platform) -> VideoPlatformConfig:
|
||||
s3_region=daily_storage.region,
|
||||
aws_role_arn=daily_storage.role_credential,
|
||||
)
|
||||
elif platform == LIVEKIT_PLATFORM:
|
||||
if not settings.LIVEKIT_URL:
|
||||
raise ValueError(
|
||||
"LIVEKIT_URL is required when platform='livekit'. "
|
||||
"Set LIVEKIT_URL environment variable."
|
||||
)
|
||||
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
|
||||
raise ValueError(
|
||||
"LIVEKIT_API_KEY and LIVEKIT_API_SECRET are required when platform='livekit'. "
|
||||
"Set LIVEKIT_API_KEY and LIVEKIT_API_SECRET environment variables."
|
||||
)
|
||||
return VideoPlatformConfig(
|
||||
api_key=settings.LIVEKIT_API_KEY,
|
||||
webhook_secret=settings.LIVEKIT_WEBHOOK_SECRET
|
||||
or settings.LIVEKIT_API_SECRET,
|
||||
api_url=settings.LIVEKIT_URL,
|
||||
s3_bucket=settings.LIVEKIT_STORAGE_AWS_BUCKET_NAME,
|
||||
s3_region=settings.LIVEKIT_STORAGE_AWS_REGION,
|
||||
aws_access_key_id=settings.LIVEKIT_STORAGE_AWS_ACCESS_KEY_ID,
|
||||
aws_access_key_secret=settings.LIVEKIT_STORAGE_AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown platform: {platform}")
|
||||
|
||||
|
||||
175
server/reflector/video_platforms/livekit.py
Normal file
175
server/reflector/video_platforms/livekit.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
LiveKit video platform client for Reflector.
|
||||
|
||||
Self-hosted, open-source alternative to Daily.co.
|
||||
Uses Track Egress for per-participant audio recording (no composite video).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from reflector.db.rooms import Room
|
||||
from reflector.livekit_api.client import LiveKitApiClient
|
||||
from reflector.livekit_api.webhooks import create_webhook_receiver, verify_webhook
|
||||
from reflector.logger import logger
|
||||
from reflector.settings import settings
|
||||
|
||||
from ..schemas.platform import Platform
|
||||
from ..utils.string import NonEmptyString
|
||||
from .base import ROOM_PREFIX_SEPARATOR, VideoPlatformClient
|
||||
from .models import MeetingData, SessionData, VideoPlatformConfig
|
||||
|
||||
|
||||
class LiveKitClient(VideoPlatformClient):
|
||||
PLATFORM_NAME: Platform = "livekit"
|
||||
TIMESTAMP_FORMAT = "%Y%m%d%H%M%S"
|
||||
|
||||
def __init__(self, config: VideoPlatformConfig):
|
||||
super().__init__(config)
|
||||
self._api_client = LiveKitApiClient(
|
||||
url=config.api_url or "",
|
||||
api_key=config.api_key,
|
||||
api_secret=config.webhook_secret, # LiveKit uses API secret for both auth and webhooks
|
||||
s3_bucket=config.s3_bucket,
|
||||
s3_region=config.s3_region,
|
||||
s3_access_key=config.aws_access_key_id,
|
||||
s3_secret_key=config.aws_access_key_secret,
|
||||
s3_endpoint=settings.LIVEKIT_STORAGE_AWS_ENDPOINT_URL,
|
||||
)
|
||||
self._webhook_receiver = create_webhook_receiver(
|
||||
api_key=config.api_key,
|
||||
api_secret=config.webhook_secret,
|
||||
)
|
||||
|
||||
async def create_meeting(
|
||||
self, room_name_prefix: NonEmptyString, end_date: datetime, room: Room
|
||||
) -> MeetingData:
|
||||
"""Create a LiveKit room for this meeting.
|
||||
|
||||
LiveKit rooms are created explicitly via API. A new room is created
|
||||
for each Reflector meeting (same pattern as Daily.co).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
timestamp = now.strftime(self.TIMESTAMP_FORMAT)
|
||||
room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{timestamp}"
|
||||
|
||||
# Calculate empty_timeout from end_date (seconds until expiry)
|
||||
# Ensure end_date is timezone-aware for subtraction
|
||||
end_date_aware = (
|
||||
end_date if end_date.tzinfo else end_date.replace(tzinfo=timezone.utc)
|
||||
)
|
||||
remaining = int((end_date_aware - now).total_seconds())
|
||||
empty_timeout = max(300, min(remaining, 86400)) # 5 min to 24 hours
|
||||
|
||||
lk_room = await self._api_client.create_room(
|
||||
name=room_name,
|
||||
empty_timeout=empty_timeout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"LiveKit room created",
|
||||
room_name=lk_room.name,
|
||||
room_sid=lk_room.sid,
|
||||
empty_timeout=empty_timeout,
|
||||
)
|
||||
|
||||
# room_url includes the server URL + room name as query param.
|
||||
# The join endpoint in rooms.py appends the token as another query param.
|
||||
# Frontend parses: ws://host:7880?room=<name>&token=<jwt>
|
||||
public_url = settings.LIVEKIT_PUBLIC_URL or settings.LIVEKIT_URL or ""
|
||||
room_url = f"{public_url}?{urlencode({'room': lk_room.name})}"
|
||||
|
||||
return MeetingData(
|
||||
meeting_id=lk_room.sid or str(uuid4()),
|
||||
room_name=lk_room.name,
|
||||
room_url=room_url,
|
||||
host_room_url=room_url,
|
||||
platform=self.PLATFORM_NAME,
|
||||
extra_data={"livekit_room_sid": lk_room.sid},
|
||||
)
|
||||
|
||||
async def get_room_sessions(self, room_name: str) -> list[SessionData]:
|
||||
"""Get current participants in a LiveKit room.
|
||||
|
||||
For historical sessions, we rely on webhook-stored data (same as Daily).
|
||||
This returns currently-connected participants.
|
||||
"""
|
||||
try:
|
||||
participants = await self._api_client.list_participants(room_name)
|
||||
return [
|
||||
SessionData(
|
||||
session_id=p.sid,
|
||||
started_at=datetime.fromtimestamp(
|
||||
p.joined_at if p.joined_at else 0, tz=timezone.utc
|
||||
),
|
||||
ended_at=None, # Still active
|
||||
)
|
||||
for p in participants
|
||||
if p.sid # Skip empty entries
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Could not list LiveKit participants (room may not exist)",
|
||||
room_name=room_name,
|
||||
error=str(e),
|
||||
)
|
||||
return []
|
||||
|
||||
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
|
||||
# LiveKit doesn't have a logo upload concept; handled in frontend theming
|
||||
return True
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, body: bytes, signature: str, timestamp: str | None = None
|
||||
) -> bool:
|
||||
"""Verify LiveKit webhook signature.
|
||||
|
||||
LiveKit sends the JWT in the Authorization header. The `signature`
|
||||
param here receives the Authorization header value.
|
||||
"""
|
||||
event = verify_webhook(self._webhook_receiver, body, signature)
|
||||
return event is not None
|
||||
|
||||
def create_access_token(
|
||||
self,
|
||||
room_name: str,
|
||||
participant_identity: str,
|
||||
participant_name: str | None = None,
|
||||
is_admin: bool = False,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a participant."""
|
||||
return self._api_client.create_access_token(
|
||||
room_name=room_name,
|
||||
participant_identity=participant_identity,
|
||||
participant_name=participant_name,
|
||||
room_admin=is_admin,
|
||||
)
|
||||
|
||||
async def start_track_egress(
|
||||
self,
|
||||
room_name: str,
|
||||
track_sid: str,
|
||||
s3_filepath: str,
|
||||
):
|
||||
"""Start Track Egress for a single audio track."""
|
||||
return await self._api_client.start_track_egress(
|
||||
room_name=room_name,
|
||||
track_sid=track_sid,
|
||||
s3_filepath=s3_filepath,
|
||||
)
|
||||
|
||||
async def list_egress(self, room_name: str | None = None):
|
||||
return await self._api_client.list_egress(room_name=room_name)
|
||||
|
||||
async def stop_egress(self, egress_id: str):
|
||||
return await self._api_client.stop_egress(egress_id=egress_id)
|
||||
|
||||
async def close(self):
|
||||
await self._api_client.close()
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
@@ -1,6 +1,11 @@
|
||||
from typing import Dict, Type
|
||||
|
||||
from ..schemas.platform import DAILY_PLATFORM, WHEREBY_PLATFORM, Platform
|
||||
from ..schemas.platform import (
|
||||
DAILY_PLATFORM,
|
||||
LIVEKIT_PLATFORM,
|
||||
WHEREBY_PLATFORM,
|
||||
Platform,
|
||||
)
|
||||
from .base import VideoPlatformClient, VideoPlatformConfig
|
||||
|
||||
_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {}
|
||||
@@ -26,10 +31,12 @@ def get_available_platforms() -> list[Platform]:
|
||||
|
||||
def _register_builtin_platforms():
|
||||
from .daily import DailyClient # noqa: PLC0415
|
||||
from .livekit import LiveKitClient # noqa: PLC0415
|
||||
from .whereby import WherebyClient # noqa: PLC0415
|
||||
|
||||
register_platform(WHEREBY_PLATFORM, WherebyClient)
|
||||
register_platform(DAILY_PLATFORM, DailyClient)
|
||||
register_platform(LIVEKIT_PLATFORM, LiveKitClient)
|
||||
|
||||
|
||||
_register_builtin_platforms()
|
||||
|
||||
190
server/reflector/views/livekit.py
Normal file
190
server/reflector/views/livekit.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""LiveKit webhook handler.
|
||||
|
||||
Processes LiveKit webhook events for participant tracking and
|
||||
Track Egress recording completion.
|
||||
|
||||
LiveKit sends webhooks as POST requests with JWT authentication
|
||||
in the Authorization header.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from reflector.db.meetings import meetings_controller
|
||||
from reflector.livekit_api.webhooks import create_webhook_receiver, verify_webhook
|
||||
from reflector.logger import logger as _logger
|
||||
from reflector.settings import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
logger = _logger.bind(platform="livekit")
|
||||
|
||||
# Module-level receiver, lazily initialized on first webhook
|
||||
_webhook_receiver = None
|
||||
|
||||
|
||||
def _get_webhook_receiver():
|
||||
global _webhook_receiver
|
||||
if _webhook_receiver is None:
|
||||
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
|
||||
raise ValueError("LiveKit not configured")
|
||||
_webhook_receiver = create_webhook_receiver(
|
||||
api_key=settings.LIVEKIT_API_KEY,
|
||||
api_secret=settings.LIVEKIT_WEBHOOK_SECRET or settings.LIVEKIT_API_SECRET,
|
||||
)
|
||||
return _webhook_receiver
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def livekit_webhook(request: Request):
|
||||
"""Handle LiveKit webhook events.
|
||||
|
||||
LiveKit webhook events include:
|
||||
- participant_joined / participant_left
|
||||
- egress_started / egress_updated / egress_ended
|
||||
- room_started / room_finished
|
||||
- track_published / track_unpublished
|
||||
"""
|
||||
if not settings.LIVEKIT_API_KEY or not settings.LIVEKIT_API_SECRET:
|
||||
raise HTTPException(status_code=500, detail="LiveKit not configured")
|
||||
|
||||
body = await request.body()
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
|
||||
receiver = _get_webhook_receiver()
|
||||
event = verify_webhook(receiver, body, auth_header)
|
||||
if event is None:
|
||||
logger.warning(
|
||||
"Invalid LiveKit webhook signature",
|
||||
has_auth=bool(auth_header),
|
||||
has_body=bool(body),
|
||||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
|
||||
event_type = event.event
|
||||
|
||||
match event_type:
|
||||
case "participant_joined":
|
||||
await _handle_participant_joined(event)
|
||||
case "participant_left":
|
||||
await _handle_participant_left(event)
|
||||
case "egress_started":
|
||||
await _handle_egress_started(event)
|
||||
case "egress_ended":
|
||||
await _handle_egress_ended(event)
|
||||
case "room_started":
|
||||
logger.info(
|
||||
"Room started",
|
||||
room_name=event.room.name if event.room else None,
|
||||
)
|
||||
case "room_finished":
|
||||
logger.info(
|
||||
"Room finished",
|
||||
room_name=event.room.name if event.room else None,
|
||||
)
|
||||
case "track_published" | "track_unpublished":
|
||||
logger.debug(
|
||||
f"Track event: {event_type}",
|
||||
room_name=event.room.name if event.room else None,
|
||||
participant=event.participant.identity if event.participant else None,
|
||||
)
|
||||
case _:
|
||||
logger.debug(
|
||||
"Unhandled LiveKit webhook event",
|
||||
event_type=event_type,
|
||||
)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _handle_participant_joined(event):
|
||||
room_name = event.room.name if event.room else None
|
||||
participant = event.participant
|
||||
|
||||
if not room_name or not participant:
|
||||
logger.warning("participant_joined: missing room or participant data")
|
||||
return
|
||||
|
||||
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||
if not meeting:
|
||||
logger.warning("participant_joined: meeting not found", room_name=room_name)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Participant joined",
|
||||
meeting_id=meeting.id,
|
||||
room_name=room_name,
|
||||
participant_identity=participant.identity,
|
||||
participant_sid=participant.sid,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_participant_left(event):
|
||||
room_name = event.room.name if event.room else None
|
||||
participant = event.participant
|
||||
|
||||
if not room_name or not participant:
|
||||
logger.warning("participant_left: missing room or participant data")
|
||||
return
|
||||
|
||||
meeting = await meetings_controller.get_by_room_name(room_name)
|
||||
if not meeting:
|
||||
logger.warning("participant_left: meeting not found", room_name=room_name)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Participant left",
|
||||
meeting_id=meeting.id,
|
||||
room_name=room_name,
|
||||
participant_identity=participant.identity,
|
||||
participant_sid=participant.sid,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_egress_started(event):
|
||||
egress = event.egress_info
|
||||
room_name = egress.room_name if egress else None
|
||||
|
||||
logger.info(
|
||||
"Egress started",
|
||||
room_name=room_name,
|
||||
egress_id=egress.egress_id if egress else None,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_egress_ended(event):
|
||||
"""Handle Track Egress completion — trigger multitrack processing."""
|
||||
egress = event.egress_info
|
||||
if not egress:
|
||||
logger.warning("egress_ended: no egress info in payload")
|
||||
return
|
||||
|
||||
room_name = egress.room_name
|
||||
|
||||
# Check egress status
|
||||
# EGRESS_COMPLETE = 3, EGRESS_FAILED = 4
|
||||
status = egress.status
|
||||
if status == 4: # EGRESS_FAILED
|
||||
logger.error(
|
||||
"Egress failed",
|
||||
room_name=room_name,
|
||||
egress_id=egress.egress_id,
|
||||
error=egress.error,
|
||||
)
|
||||
return
|
||||
|
||||
# Extract output file info from egress results
|
||||
file_results = list(egress.file_results)
|
||||
|
||||
logger.info(
|
||||
"Egress ended",
|
||||
room_name=room_name,
|
||||
egress_id=egress.egress_id,
|
||||
status=status,
|
||||
num_files=len(file_results),
|
||||
filenames=[f.filename for f in file_results] if file_results else [],
|
||||
)
|
||||
|
||||
# Track Egress produces one file per egress request.
|
||||
# The multitrack pipeline will be triggered separately once all tracks
|
||||
# for a room are collected (via periodic polling or explicit trigger).
|
||||
# TODO: Implement track collection and pipeline trigger
|
||||
@@ -598,4 +598,22 @@ async def rooms_join_meeting(
|
||||
meeting = meeting.model_copy()
|
||||
meeting.room_url = add_query_param(meeting.room_url, "t", token)
|
||||
|
||||
elif meeting.platform == "livekit":
|
||||
client = create_platform_client(meeting.platform)
|
||||
participant_identity = user_id or f"anon-{meeting_id[:8]}"
|
||||
participant_name = (
|
||||
getattr(user, "name", None) or participant_identity
|
||||
if user
|
||||
else participant_identity
|
||||
)
|
||||
token = client.create_access_token(
|
||||
room_name=meeting.room_name,
|
||||
participant_identity=participant_identity,
|
||||
participant_name=participant_name,
|
||||
is_admin=user_id == room.user_id if user_id else False,
|
||||
)
|
||||
meeting = meeting.model_copy()
|
||||
# For LiveKit, room_url is the WS URL; token goes as a query param
|
||||
meeting.room_url = add_query_param(meeting.room_url, "token", token)
|
||||
|
||||
return meeting
|
||||
|
||||
@@ -83,7 +83,11 @@ def build_beat_schedule(
|
||||
else:
|
||||
logger.info("Daily.co beat tasks disabled (no DAILY_API_KEY)")
|
||||
|
||||
_any_platform = _whereby_enabled or _daily_enabled
|
||||
_livekit_enabled = bool(settings.LIVEKIT_API_KEY and settings.LIVEKIT_URL)
|
||||
if _livekit_enabled:
|
||||
logger.info("LiveKit platform detected")
|
||||
|
||||
_any_platform = _whereby_enabled or _daily_enabled or _livekit_enabled
|
||||
if _any_platform:
|
||||
beat_schedule["process_meetings"] = {
|
||||
"task": "reflector.worker.process.process_meetings",
|
||||
|
||||
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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "livekit-api"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "livekit-protocol" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "types-protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/0a/ad3cce124e608c056d6390244ec4dd18c8a4b5f055693a95831da2119af7/livekit_api-1.1.0.tar.gz", hash = "sha256:f94c000534d3a9b506e6aed2f35eb88db1b23bdea33bb322f0144c4e9f73934e", size = 16649, upload-time = "2025-12-02T19:37:11.452Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/b9/8d8515e3e0e629ab07d399cf858b8fc7e0a02bbf6384a6592b285264b4b9/livekit_api-1.1.0-py3-none-any.whl", hash = "sha256:bfc1c2c65392eb3f580a2c28108269f0e79873f053578a677eee7bb1de8aa8fb", size = 19620, upload-time = "2025-12-02T19:37:10.075Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "livekit-protocol"
|
||||
version = "1.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "protobuf" },
|
||||
{ name = "types-protobuf" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ca/d15e2a2cc8c8aa4ba621fe5f9ffd1806d88ac91c7b8fa4c09a3c0304dd92/livekit_protocol-1.1.3.tar.gz", hash = "sha256:cb4948d2513e81d91583f4a795bf80faa9026cedda509c5714999c7e33564287", size = 88746, upload-time = "2026-03-18T05:25:43.562Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/0e/f3d3e48628294df4559cffd0f8e1adf030127029e5a8da9beff9979090a0/livekit_protocol-1.1.3-py3-none-any.whl", hash = "sha256:fdae5640e064ab6549ec3d62d8bac75a3ef44d7ea73716069b419cbe8b360a5c", size = 107498, upload-time = "2026-03-18T05:25:42.077Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llama-cloud"
|
||||
version = "0.1.35"
|
||||
@@ -3364,6 +3393,7 @@ dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "icalendar" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "livekit-api" },
|
||||
{ name = "llama-index" },
|
||||
{ name = "llama-index-llms-openai-like" },
|
||||
{ name = "openai" },
|
||||
@@ -3445,6 +3475,7 @@ requires-dist = [
|
||||
{ name = "httpx", specifier = ">=0.24.1" },
|
||||
{ name = "icalendar", specifier = ">=6.0.0" },
|
||||
{ name = "jsonschema", specifier = ">=4.23.0" },
|
||||
{ name = "livekit-api", specifier = ">=1.1.0" },
|
||||
{ name = "llama-index", specifier = ">=0.12.52" },
|
||||
{ name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },
|
||||
{ name = "openai", specifier = ">=1.59.7" },
|
||||
@@ -4399,6 +4430,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/42/3efaf858001d2c2913de7f354563e3a3a2f0decae3efe98427125a8f441e/typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855", size = 46317, upload-time = "2025-05-26T14:30:30.523Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-protobuf"
|
||||
version = "6.32.1.20260221"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.14.1"
|
||||
|
||||
Reference in New Issue
Block a user