mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
* llm instructions * vibe dailyco * vibe dailyco * doc update (vibe) * dont show recording ui on call * stub processor (vibe) * stub processor (vibe) self-review * stub processor (vibe) self-review * chore(main): release 0.14.0 (#670) * Add multitrack pipeline * Mixdown audio tracks * Mixdown with pyav filter graph * Trigger multitrack processing for daily recordings * apply platform from envs in priority: non-dry * Use explicit track keys for processing * Align tracks of a multitrack recording * Generate waveforms for the mixed audio * Emit multriack pipeline events * Fix multitrack pipeline track alignment * dailico docs * Enable multitrack reprocessing * modal temp files uniform names, cleanup. remove llm temporary docs * docs cleanup * dont proceed with raw recordings if any of the downloads fail * dry transcription pipelines * remove is_miltitrack * comments * explicit dailyco room name * docs * remove stub data/method * frontend daily/whereby code self-review (no-mistake) * frontend daily/whereby code self-review (no-mistakes) * frontend daily/whereby code self-review (no-mistakes) * consent cleanup for multitrack (no-mistakes) * llm fun * remove extra comments * fix tests * merge migrations * Store participant names * Get participants by meeting session id * pop back main branch migration * s3 paddington (no-mistakes) * comment * pr comments * pr comments * pr comments * platform / meeting cleanup * Use participant names in summary generation * platform assignment to meeting at controller level * pr comment * room playform properly default none * room playform properly default none * restore migration lost * streaming WIP * extract storage / use common storage / proper env vars for storage * fix mocks tests * remove fall back * streaming for multifile * cenrtal storage abstraction (no-mistakes) * remove dead code / vars * Set participant user id for authenticated users * whereby recording name parsing fix * whereby recording name parsing fix * more file stream * storage dry + tests * remove homemade boto3 streaming and use proper boto * update migration guide * webhook creation script - print uuid --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com> Co-authored-by: Mathieu Virbel <mat@meltingrocks.com> Co-authored-by: Sergey Mankovsky <sergey@monadical.com>
234 lines
7.2 KiB
Python
234 lines
7.2 KiB
Python
import json
|
|
from typing import Any, Dict, Literal
|
|
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel
|
|
|
|
from reflector.db.meetings import meetings_controller
|
|
from reflector.logger import logger as _logger
|
|
from reflector.settings import settings
|
|
from reflector.utils.daily import DailyRoomName
|
|
from reflector.video_platforms.factory import create_platform_client
|
|
from reflector.worker.process import process_multitrack_recording
|
|
|
|
router = APIRouter()
|
|
|
|
logger = _logger.bind(platform="daily")
|
|
|
|
|
|
class DailyTrack(BaseModel):
|
|
type: Literal["audio", "video"]
|
|
s3Key: str
|
|
size: int
|
|
|
|
|
|
class DailyWebhookEvent(BaseModel):
|
|
version: str
|
|
type: str
|
|
id: str
|
|
payload: Dict[str, Any]
|
|
event_ts: float
|
|
|
|
|
|
def _extract_room_name(event: DailyWebhookEvent) -> DailyRoomName | None:
|
|
"""Extract room name from Daily event payload.
|
|
|
|
Daily.co API inconsistency:
|
|
- participant.* events use "room" field
|
|
- recording.* events use "room_name" field
|
|
"""
|
|
return event.payload.get("room_name") or event.payload.get("room")
|
|
|
|
|
|
@router.post("/webhook")
|
|
async def webhook(request: Request):
|
|
"""Handle Daily webhook events.
|
|
|
|
Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook
|
|
state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py
|
|
"""
|
|
body = await request.body()
|
|
signature = request.headers.get("X-Webhook-Signature", "")
|
|
timestamp = request.headers.get("X-Webhook-Timestamp", "")
|
|
|
|
client = create_platform_client("daily")
|
|
|
|
# TEMPORARY: Bypass signature check for testing
|
|
# TODO: Remove this after testing is complete
|
|
BYPASS_FOR_TESTING = True
|
|
if not BYPASS_FOR_TESTING:
|
|
if not client.verify_webhook_signature(body, signature, timestamp):
|
|
logger.warning(
|
|
"Invalid webhook signature",
|
|
signature=signature,
|
|
timestamp=timestamp,
|
|
has_body=bool(body),
|
|
)
|
|
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
|
|
|
try:
|
|
body_json = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
raise HTTPException(status_code=422, detail="Invalid JSON")
|
|
|
|
if body_json.get("test") == "test":
|
|
logger.info("Received Daily webhook test event")
|
|
return {"status": "ok"}
|
|
|
|
# Parse as actual event
|
|
try:
|
|
event = DailyWebhookEvent(**body_json)
|
|
except Exception as e:
|
|
logger.error("Failed to parse webhook event", error=str(e), body=body.decode())
|
|
raise HTTPException(status_code=422, detail="Invalid event format")
|
|
|
|
# Handle participant events
|
|
if event.type == "participant.joined":
|
|
await _handle_participant_joined(event)
|
|
elif event.type == "participant.left":
|
|
await _handle_participant_left(event)
|
|
elif event.type == "recording.started":
|
|
await _handle_recording_started(event)
|
|
elif event.type == "recording.ready-to-download":
|
|
await _handle_recording_ready(event)
|
|
elif event.type == "recording.error":
|
|
await _handle_recording_error(event)
|
|
else:
|
|
logger.warning(
|
|
"Unhandled Daily webhook event type",
|
|
event_type=event.type,
|
|
payload=event.payload,
|
|
)
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
async def _handle_participant_joined(event: DailyWebhookEvent):
|
|
daily_room_name = _extract_room_name(event)
|
|
if not daily_room_name:
|
|
logger.warning("participant.joined: no room in payload", payload=event.payload)
|
|
return
|
|
|
|
meeting = await meetings_controller.get_by_room_name(daily_room_name)
|
|
if meeting:
|
|
await meetings_controller.increment_num_clients(meeting.id)
|
|
logger.info(
|
|
"Participant joined",
|
|
meeting_id=meeting.id,
|
|
room_name=daily_room_name,
|
|
recording_type=meeting.recording_type,
|
|
recording_trigger=meeting.recording_trigger,
|
|
)
|
|
else:
|
|
logger.warning(
|
|
"participant.joined: meeting not found", room_name=daily_room_name
|
|
)
|
|
|
|
|
|
async def _handle_participant_left(event: DailyWebhookEvent):
|
|
room_name = _extract_room_name(event)
|
|
if not room_name:
|
|
return
|
|
|
|
meeting = await meetings_controller.get_by_room_name(room_name)
|
|
if meeting:
|
|
await meetings_controller.decrement_num_clients(meeting.id)
|
|
|
|
|
|
async def _handle_recording_started(event: DailyWebhookEvent):
|
|
room_name = _extract_room_name(event)
|
|
if not room_name:
|
|
logger.warning(
|
|
"recording.started: no room_name in payload", payload=event.payload
|
|
)
|
|
return
|
|
|
|
meeting = await meetings_controller.get_by_room_name(room_name)
|
|
if meeting:
|
|
logger.info(
|
|
"Recording started",
|
|
meeting_id=meeting.id,
|
|
room_name=room_name,
|
|
recording_id=event.payload.get("recording_id"),
|
|
platform="daily",
|
|
)
|
|
else:
|
|
logger.warning("recording.started: meeting not found", room_name=room_name)
|
|
|
|
|
|
async def _handle_recording_ready(event: DailyWebhookEvent):
|
|
"""Handle recording ready for download event.
|
|
|
|
Daily.co webhook payload for raw-tracks recordings:
|
|
{
|
|
"recording_id": "...",
|
|
"room_name": "test2-20251009192341",
|
|
"tracks": [
|
|
{"type": "audio", "s3Key": "monadical/test2-.../uuid-cam-audio-123.webm", "size": 400000},
|
|
{"type": "video", "s3Key": "monadical/test2-.../uuid-cam-video-456.webm", "size": 30000000}
|
|
]
|
|
}
|
|
"""
|
|
room_name = _extract_room_name(event)
|
|
recording_id = event.payload.get("recording_id")
|
|
tracks_raw = event.payload.get("tracks", [])
|
|
|
|
if not room_name or not tracks_raw:
|
|
logger.warning(
|
|
"recording.ready-to-download: missing room_name or tracks",
|
|
room_name=room_name,
|
|
has_tracks=bool(tracks_raw),
|
|
payload=event.payload,
|
|
)
|
|
return
|
|
|
|
try:
|
|
tracks = [DailyTrack(**t) for t in tracks_raw]
|
|
except Exception as e:
|
|
logger.error(
|
|
"recording.ready-to-download: invalid tracks structure",
|
|
error=str(e),
|
|
tracks=tracks_raw,
|
|
)
|
|
return
|
|
|
|
logger.info(
|
|
"Recording ready for download",
|
|
room_name=room_name,
|
|
recording_id=recording_id,
|
|
num_tracks=len(tracks),
|
|
platform="daily",
|
|
)
|
|
|
|
bucket_name = settings.DAILYCO_STORAGE_AWS_BUCKET_NAME
|
|
if not bucket_name:
|
|
logger.error(
|
|
"DAILYCO_STORAGE_AWS_BUCKET_NAME not configured; cannot process Daily recording"
|
|
)
|
|
return
|
|
|
|
track_keys = [t.s3Key for t in tracks if t.type == "audio"]
|
|
|
|
process_multitrack_recording.delay(
|
|
bucket_name=bucket_name,
|
|
daily_room_name=room_name,
|
|
recording_id=recording_id,
|
|
track_keys=track_keys,
|
|
)
|
|
|
|
|
|
async def _handle_recording_error(event: DailyWebhookEvent):
|
|
room_name = _extract_room_name(event)
|
|
error = event.payload.get("error", "Unknown error")
|
|
|
|
if room_name:
|
|
meeting = await meetings_controller.get_by_room_name(room_name)
|
|
if meeting:
|
|
logger.error(
|
|
"Recording error",
|
|
meeting_id=meeting.id,
|
|
room_name=room_name,
|
|
error=error,
|
|
platform="daily",
|
|
)
|