From 8e438ca285152bd48fdc42767e706fb448d3525c Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Mon, 24 Nov 2025 22:24:03 -0500 Subject: [PATCH 01/26] feat: dailyco poll (#730) * dailyco api module (no-mistakes) * daily co library self-review * uncurse * self-review: daily resource leak, uniform types, enable_recording bomb, daily custom error, video_platforms/daily typing, daily timestamp dry * dailyco docs parser * phase 1-2 of daily poll * dailyco poll (no-mistakes) * poll docs * fix tests * forgotten utils file * remove generated daily docs * pr comments * dailyco poll pr review and self-review * daily recording poll api fix * daily recording poll api fix * review * review * fix tests --------- Co-authored-by: Igor Loskutov --- server/docs/video-platforms/README.md | 4 +- server/reflector/dailyco_api/__init__.py | 12 + server/reflector/dailyco_api/client.py | 68 ++- server/reflector/dailyco_api/responses.py | 13 +- server/reflector/dailyco_api/webhooks.py | 74 ++- .../db/daily_participant_sessions.py | 60 +++ server/reflector/db/meetings.py | 7 +- server/reflector/db/recordings.py | 15 +- server/reflector/llm.py | 28 +- server/reflector/settings.py | 1 - server/reflector/utils/daily.py | 63 ++- server/reflector/video_platforms/base.py | 4 - server/reflector/video_platforms/daily.py | 24 +- server/reflector/video_platforms/whereby.py | 3 - server/reflector/views/daily.py | 288 ++++------- server/reflector/worker/app.py | 8 + server/reflector/worker/process.py | 480 ++++++++++++++---- server/tests/mocks/mock_platform.py | 6 - .../tests/test_daily_room_presence_polling.py | 466 +++++++++++++++++ server/tests/test_poll_daily_recordings.py | 193 +++++++ server/tests/test_utils_daily.py | 49 +- 21 files changed, 1529 insertions(+), 337 deletions(-) create mode 100644 server/tests/test_daily_room_presence_polling.py create mode 100644 server/tests/test_poll_daily_recordings.py diff --git a/server/docs/video-platforms/README.md b/server/docs/video-platforms/README.md index 45a615c3..15734db3 100644 --- a/server/docs/video-platforms/README.md +++ b/server/docs/video-platforms/README.md @@ -89,7 +89,9 @@ This document explains how Reflector receives and identifies multitrack audio re --- -## Daily.co (Webhook-based) +## Daily.co + +**Note:** Primary discovery via polling (`poll_daily_recordings`), webhooks as backup. Daily.co uses **webhooks** to notify Reflector when recordings are ready. diff --git a/server/reflector/dailyco_api/__init__.py b/server/reflector/dailyco_api/__init__.py index 1a65478b..8ef95274 100644 --- a/server/reflector/dailyco_api/__init__.py +++ b/server/reflector/dailyco_api/__init__.py @@ -46,10 +46,16 @@ from .webhook_utils import ( from .webhooks import ( DailyTrack, DailyWebhookEvent, + DailyWebhookEventUnion, + ParticipantJoinedEvent, ParticipantJoinedPayload, + ParticipantLeftEvent, ParticipantLeftPayload, + RecordingErrorEvent, RecordingErrorPayload, + RecordingReadyEvent, RecordingReadyToDownloadPayload, + RecordingStartedEvent, RecordingStartedPayload, ) @@ -78,11 +84,17 @@ __all__ = [ "WebhookResponse", # Webhooks "DailyWebhookEvent", + "DailyWebhookEventUnion", "DailyTrack", + "ParticipantJoinedEvent", "ParticipantJoinedPayload", + "ParticipantLeftEvent", "ParticipantLeftPayload", + "RecordingStartedEvent", "RecordingStartedPayload", + "RecordingReadyEvent", "RecordingReadyToDownloadPayload", + "RecordingErrorEvent", "RecordingErrorPayload", # Webhook utilities "verify_webhook_signature", diff --git a/server/reflector/dailyco_api/client.py b/server/reflector/dailyco_api/client.py index 24221bb2..e28e1a72 100644 --- a/server/reflector/dailyco_api/client.py +++ b/server/reflector/dailyco_api/client.py @@ -327,18 +327,8 @@ class DailyApiClient: async def get_recording(self, recording_id: NonEmptyString) -> RecordingResponse: """ + https://docs.daily.co/reference/rest-api/recordings/get-recording-information Get recording metadata and status. - - Reference: https://docs.daily.co/reference/rest-api/recordings - - Args: - recording_id: Daily.co recording ID - - Returns: - Recording metadata including status, duration, and S3 info - - Raises: - httpx.HTTPStatusError: If API request fails """ client = await self._get_client() response = await client.get( @@ -349,6 +339,62 @@ class DailyApiClient: data = await self._handle_response(response, "get_recording") return RecordingResponse(**data) + async def list_recordings( + self, + room_name: NonEmptyString | None = None, + starting_after: str | None = None, + ending_before: str | None = None, + limit: int = 100, + ) -> list[RecordingResponse]: + """ + List recordings with optional filters. + + Reference: https://docs.daily.co/reference/rest-api/recordings + + Args: + room_name: Filter by room name + starting_after: Pagination cursor - recording ID to start after + ending_before: Pagination cursor - recording ID to end before + limit: Max results per page (default 100, max 100) + + Note: starting_after/ending_before are pagination cursors (recording IDs), + NOT time filters. API returns recordings in reverse chronological order. + """ + client = await self._get_client() + + params = {"limit": limit} + if room_name: + params["room_name"] = room_name + if starting_after: + params["starting_after"] = starting_after + if ending_before: + params["ending_before"] = ending_before + + response = await client.get( + f"{self.base_url}/recordings", + headers=self.headers, + params=params, + ) + + data = await self._handle_response(response, "list_recordings") + + if not isinstance(data, dict) or "data" not in data: + logger.error( + "Daily.co API returned unexpected format for list_recordings", + data_type=type(data).__name__, + data_keys=list(data.keys()) if isinstance(data, dict) else None, + data_sample=str(data)[:500], + room_name=room_name, + operation="list_recordings", + ) + raise httpx.HTTPStatusError( + message=f"Unexpected response format from list_recordings: {type(data).__name__}", + request=response.request, + response=response, + ) + + return [RecordingResponse(**r) for r in data["data"]] + # ============================================================================ # MEETING TOKENS # ============================================================================ diff --git a/server/reflector/dailyco_api/responses.py b/server/reflector/dailyco_api/responses.py index 4eb84245..3dc18815 100644 --- a/server/reflector/dailyco_api/responses.py +++ b/server/reflector/dailyco_api/responses.py @@ -6,6 +6,7 @@ from typing import Any, Dict, List, Literal from pydantic import BaseModel, Field +from reflector.dailyco_api.webhooks import DailyTrack from reflector.utils.string import NonEmptyString # not documented in daily; we fill it according to observations @@ -131,12 +132,22 @@ class RecordingResponse(BaseModel): status: RecordingStatus = Field( description="Recording status ('in-progress' or 'finished')" ) - max_participants: int = Field(description="Maximum participants during recording") + max_participants: int | None = Field( + None, description="Maximum participants during recording (may be missing)" + ) duration: int = Field(description="Recording duration in seconds") share_token: NonEmptyString | None = Field( None, description="Token for sharing recording" ) s3: RecordingS3Info | None = Field(None, description="S3 bucket information") + tracks: list[DailyTrack] = Field( + default_factory=list, + description="Track list for raw-tracks recordings (always array, never null)", + ) + # this is not a mistake but a deliberate Daily.co naming decision + mtgSessionId: NonEmptyString | None = Field( + None, description="Meeting session identifier (may be missing)" + ) class MeetingTokenResponse(BaseModel): diff --git a/server/reflector/dailyco_api/webhooks.py b/server/reflector/dailyco_api/webhooks.py index 862f4996..e0ff1f5c 100644 --- a/server/reflector/dailyco_api/webhooks.py +++ b/server/reflector/dailyco_api/webhooks.py @@ -4,7 +4,7 @@ Daily.co Webhook Event Models Reference: https://docs.daily.co/reference/rest-api/webhooks """ -from typing import Any, Dict, Literal +from typing import Annotated, Any, Dict, Literal, Union from pydantic import BaseModel, Field, field_validator @@ -197,3 +197,75 @@ class RecordingErrorPayload(BaseModel): _normalize_timestamp = field_validator("timestamp", mode="before")( normalize_timestamp_to_int ) + + +class ParticipantJoinedEvent(BaseModel): + version: NonEmptyString + type: Literal["participant.joined"] + id: NonEmptyString + payload: ParticipantJoinedPayload + event_ts: int + + _normalize_event_ts = field_validator("event_ts", mode="before")( + normalize_timestamp_to_int + ) + + +class ParticipantLeftEvent(BaseModel): + version: NonEmptyString + type: Literal["participant.left"] + id: NonEmptyString + payload: ParticipantLeftPayload + event_ts: int + + _normalize_event_ts = field_validator("event_ts", mode="before")( + normalize_timestamp_to_int + ) + + +class RecordingStartedEvent(BaseModel): + version: NonEmptyString + type: Literal["recording.started"] + id: NonEmptyString + payload: RecordingStartedPayload + event_ts: int + + _normalize_event_ts = field_validator("event_ts", mode="before")( + normalize_timestamp_to_int + ) + + +class RecordingReadyEvent(BaseModel): + version: NonEmptyString + type: Literal["recording.ready-to-download"] + id: NonEmptyString + payload: RecordingReadyToDownloadPayload + event_ts: int + + _normalize_event_ts = field_validator("event_ts", mode="before")( + normalize_timestamp_to_int + ) + + +class RecordingErrorEvent(BaseModel): + version: NonEmptyString + type: Literal["recording.error"] + id: NonEmptyString + payload: RecordingErrorPayload + event_ts: int + + _normalize_event_ts = field_validator("event_ts", mode="before")( + normalize_timestamp_to_int + ) + + +DailyWebhookEventUnion = Annotated[ + Union[ + ParticipantJoinedEvent, + ParticipantLeftEvent, + RecordingStartedEvent, + RecordingReadyEvent, + RecordingErrorEvent, + ], + Field(discriminator="type"), +] diff --git a/server/reflector/db/daily_participant_sessions.py b/server/reflector/db/daily_participant_sessions.py index 5fac1912..4326b3c0 100644 --- a/server/reflector/db/daily_participant_sessions.py +++ b/server/reflector/db/daily_participant_sessions.py @@ -165,5 +165,65 @@ class DailyParticipantSessionController: results = await get_database().fetch_all(query) return [DailyParticipantSession(**result) for result in results] + async def get_all_sessions_for_meeting( + self, meeting_id: NonEmptyString + ) -> dict[NonEmptyString, DailyParticipantSession]: + query = daily_participant_sessions.select().where( + daily_participant_sessions.c.meeting_id == meeting_id + ) + results = await get_database().fetch_all(query) + # TODO DailySessionId custom type + return {row["session_id"]: DailyParticipantSession(**row) for row in results} + + async def batch_upsert_sessions( + self, sessions: list[DailyParticipantSession] + ) -> None: + """Upsert multiple sessions in single query. + + Uses ON CONFLICT for idempotency. Updates user_name on conflict since they may change it during a meeting. + + """ + if not sessions: + return + + values = [session.model_dump() for session in sessions] + query = insert(daily_participant_sessions).values(values) + query = query.on_conflict_do_update( + index_elements=["id"], + set_={ + # Preserve existing left_at to prevent race conditions + "left_at": sa.func.coalesce( + daily_participant_sessions.c.left_at, + query.excluded.left_at, + ), + "user_name": query.excluded.user_name, + }, + ) + await get_database().execute(query) + + async def batch_close_sessions( + self, session_ids: list[NonEmptyString], left_at: datetime + ) -> None: + """Mark multiple sessions as left in single query. + + Only updates sessions where left_at is NULL (protects already-closed sessions). + + Left_at mismatch for existing sessions is ignored, assumed to be not important issue if ever happens. + """ + if not session_ids: + return + + query = ( + daily_participant_sessions.update() + .where( + sa.and_( + daily_participant_sessions.c.id.in_(session_ids), + daily_participant_sessions.c.left_at.is_(None), + ) + ) + .values(left_at=left_at) + ) + await get_database().execute(query) + daily_participant_sessions_controller = DailyParticipantSessionController() diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py index 6912b285..9c290fa5 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -146,8 +146,11 @@ class MeetingController: await get_database().execute(query) return meeting - async def get_all_active(self) -> list[Meeting]: - query = meetings.select().where(meetings.c.is_active) + async def get_all_active(self, platform: str | None = None) -> list[Meeting]: + conditions = [meetings.c.is_active] + if platform is not None: + conditions.append(meetings.c.platform == platform) + query = meetings.select().where(sa.and_(*conditions)) results = await get_database().fetch_all(query) return [Meeting(**result) for result in results] diff --git a/server/reflector/db/recordings.py b/server/reflector/db/recordings.py index bde4afa5..c67b8413 100644 --- a/server/reflector/db/recordings.py +++ b/server/reflector/db/recordings.py @@ -44,12 +44,14 @@ class RecordingController: await get_database().execute(query) return recording - async def get_by_id(self, id: str) -> Recording: + async def get_by_id(self, id: str) -> Recording | None: query = recordings.select().where(recordings.c.id == id) result = await get_database().fetch_one(query) return Recording(**result) if result else None - async def get_by_object_key(self, bucket_name: str, object_key: str) -> Recording: + async def get_by_object_key( + self, bucket_name: str, object_key: str + ) -> Recording | None: query = recordings.select().where( recordings.c.bucket_name == bucket_name, recordings.c.object_key == object_key, @@ -61,5 +63,14 @@ class RecordingController: query = recordings.delete().where(recordings.c.id == id) await get_database().execute(query) + # no check for existence + async def get_by_ids(self, recording_ids: list[str]) -> list[Recording]: + if not recording_ids: + return [] + + query = recordings.select().where(recordings.c.id.in_(recording_ids)) + results = await get_database().fetch_all(query) + return [Recording(**row) for row in results] + recordings_controller = RecordingController() diff --git a/server/reflector/llm.py b/server/reflector/llm.py index eed50e4a..09dab3d2 100644 --- a/server/reflector/llm.py +++ b/server/reflector/llm.py @@ -1,3 +1,4 @@ +import logging from typing import Type, TypeVar from llama_index.core import Settings @@ -5,7 +6,7 @@ from llama_index.core.output_parsers import PydanticOutputParser from llama_index.core.program import LLMTextCompletionProgram from llama_index.core.response_synthesizers import TreeSummarize from llama_index.llms.openai_like import OpenAILike -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError T = TypeVar("T", bound=BaseModel) @@ -61,6 +62,8 @@ class LLM: tone_name: str | None = None, ) -> T: """Get structured output from LLM for non-function-calling models""" + logger = logging.getLogger(__name__) + summarizer = TreeSummarize(verbose=True) response = await summarizer.aget_response(prompt, texts, tone_name=tone_name) @@ -76,8 +79,25 @@ class LLM: "Please structure the above information in the following JSON format:" ) - output = await program.acall( - analysis=str(response), format_instructions=format_instructions - ) + try: + output = await program.acall( + analysis=str(response), format_instructions=format_instructions + ) + except ValidationError as e: + # Extract the raw JSON from the error details + errors = e.errors() + if errors and "input" in errors[0]: + raw_json = errors[0]["input"] + logger.error( + f"JSON validation failed for {output_cls.__name__}. " + f"Full raw JSON output:\n{raw_json}\n" + f"Validation errors: {errors}" + ) + else: + logger.error( + f"JSON validation failed for {output_cls.__name__}. " + f"Validation errors: {errors}" + ) + raise return output diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 0e3fb3f7..1ec46d94 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -138,7 +138,6 @@ class Settings(BaseSettings): DAILY_WEBHOOK_UUID: str | None = ( None # Webhook UUID for this environment. Not used by production code ) - # Platform Configuration DEFAULT_VIDEO_PLATFORM: Platform = WHEREBY_PLATFORM diff --git a/server/reflector/utils/daily.py b/server/reflector/utils/daily.py index 1c3b367c..72242f78 100644 --- a/server/reflector/utils/daily.py +++ b/server/reflector/utils/daily.py @@ -1,6 +1,67 @@ +import os +import re +from typing import NamedTuple + from reflector.utils.string import NonEmptyString -DailyRoomName = str +DailyRoomName = NonEmptyString + + +class DailyRecordingFilename(NamedTuple): + """Parsed components from Daily.co recording filename. + + Format: {recording_start_ts}-{participant_id}-cam-audio-{track_start_ts} + Example: 1763152299562-12f0b87c-97d4-4dd3-a65c-cee1f854a79c-cam-audio-1763152314582 + + Note: S3 object keys have no extension, but browsers add .webm when downloading + from S3 UI due to MIME type headers. If you download manually and wonder. + """ + + recording_start_ts: int + participant_id: str + track_start_ts: int + + +def parse_daily_recording_filename(filename: str) -> DailyRecordingFilename: + """Parse Daily.co recording filename to extract timestamps and participant ID. + + Args: + filename: Full path or basename of Daily.co recording file + Format: {recording_start_ts}-{participant_id}-cam-audio-{track_start_ts} + + Returns: + DailyRecordingFilename with parsed components + + Raises: + ValueError: If filename doesn't match expected format + + Examples: + >>> parse_daily_recording_filename("1763152299562-12f0b87c-97d4-4dd3-a65c-cee1f854a79c-cam-audio-1763152314582") + DailyRecordingFilename(recording_start_ts=1763152299562, participant_id='12f0b87c-97d4-4dd3-a65c-cee1f854a79c', track_start_ts=1763152314582) + """ + base = os.path.basename(filename) + pattern = r"(\d{13,})-([0-9a-fA-F-]{36})-cam-audio-(\d{13,})" + match = re.search(pattern, base) + + if not match: + raise ValueError( + f"Invalid Daily.co recording filename: {filename}. " + f"Expected format: {{recording_start_ts}}-{{participant_id}}-cam-audio-{{track_start_ts}}" + ) + + recording_start_ts = int(match.group(1)) + participant_id = match.group(2) + track_start_ts = int(match.group(3)) + + return DailyRecordingFilename( + recording_start_ts=recording_start_ts, + participant_id=participant_id, + track_start_ts=track_start_ts, + ) + + +def recording_lock_key(recording_id: NonEmptyString) -> NonEmptyString: + return f"recording:{recording_id}" def extract_base_room_name(daily_room_name: DailyRoomName) -> NonEmptyString: diff --git a/server/reflector/video_platforms/base.py b/server/reflector/video_platforms/base.py index 877114f7..90c4ffc2 100644 --- a/server/reflector/video_platforms/base.py +++ b/server/reflector/video_platforms/base.py @@ -30,10 +30,6 @@ class VideoPlatformClient(ABC): """Get session history for a room.""" pass - @abstractmethod - async def delete_room(self, room_name: str) -> bool: - pass - @abstractmethod async def upload_logo(self, room_name: str, logo_path: str) -> bool: pass diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py index 7485cc95..2b4d2461 100644 --- a/server/reflector/video_platforms/daily.py +++ b/server/reflector/video_platforms/daily.py @@ -19,6 +19,7 @@ from reflector.db.rooms import Room from reflector.logger import logger from reflector.storage import get_dailyco_storage +from ..dailyco_api.responses import RecordingStatus from ..schemas.platform import Platform from ..utils.daily import DailyRoomName from ..utils.string import NonEmptyString @@ -130,10 +131,25 @@ class DailyClient(VideoPlatformClient): async def get_recording(self, recording_id: str) -> RecordingResponse: return await self._api_client.get_recording(recording_id) - async def delete_room(self, room_name: str) -> bool: - """Delete a room (idempotent - succeeds even if room doesn't exist).""" - await self._api_client.delete_room(room_name) - return True + async def list_recordings( + self, + room_name: NonEmptyString | None = None, + starting_after: str | None = None, + ending_before: str | None = None, + limit: int = 100, + ) -> list[RecordingResponse]: + return await self._api_client.list_recordings( + room_name=room_name, + starting_after=starting_after, + ending_before=ending_before, + limit=limit, + ) + + async def get_recording_status( + self, recording_id: NonEmptyString + ) -> RecordingStatus: + recording = await self.get_recording(recording_id) + return recording.status async def upload_logo(self, room_name: str, logo_path: str) -> bool: return True diff --git a/server/reflector/video_platforms/whereby.py b/server/reflector/video_platforms/whereby.py index f4775e89..9ef3128c 100644 --- a/server/reflector/video_platforms/whereby.py +++ b/server/reflector/video_platforms/whereby.py @@ -122,9 +122,6 @@ class WherebyClient(VideoPlatformClient): for s in results ] - async def delete_room(self, room_name: str) -> bool: - return True - async def upload_logo(self, room_name: str, logo_path: str) -> bool: async with httpx.AsyncClient() as client: with open(logo_path, "rb") as f: diff --git a/server/reflector/views/daily.py b/server/reflector/views/daily.py index 733c70a3..cbdac409 100644 --- a/server/reflector/views/daily.py +++ b/server/reflector/views/daily.py @@ -1,24 +1,25 @@ import json -from datetime import datetime, timezone +from typing import assert_never from fastapi import APIRouter, HTTPException, Request +from pydantic import TypeAdapter from reflector.dailyco_api import ( - DailyTrack, - DailyWebhookEvent, - extract_room_name, - parse_recording_error, -) -from reflector.db import get_database -from reflector.db.daily_participant_sessions import ( - DailyParticipantSession, - daily_participant_sessions_controller, + DailyWebhookEventUnion, + ParticipantJoinedEvent, + ParticipantLeftEvent, + RecordingErrorEvent, + RecordingReadyEvent, + RecordingStartedEvent, ) from reflector.db.meetings import meetings_controller from reflector.logger import logger as _logger from reflector.settings import settings from reflector.video_platforms.factory import create_platform_client -from reflector.worker.process import process_multitrack_recording +from reflector.worker.process import ( + poll_daily_room_presence_task, + process_multitrack_recording, +) router = APIRouter() @@ -74,173 +75,83 @@ async def webhook(request: Request): logger.info("Received Daily webhook test event") return {"status": "ok"} + event_adapter = TypeAdapter(DailyWebhookEventUnion) try: - event = DailyWebhookEvent(**body_json) + event = event_adapter.validate_python(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") - 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, - ) + match event: + case ParticipantJoinedEvent(): + await _handle_participant_joined(event) + case ParticipantLeftEvent(): + await _handle_participant_left(event) + case RecordingStartedEvent(): + await _handle_recording_started(event) + case RecordingReadyEvent(): + await _handle_recording_ready(event) + case RecordingErrorEvent(): + await _handle_recording_error(event) + case _: + assert_never(event) return {"status": "ok"} -""" -{ - "version": "1.0.0", - "type": "participant.joined", - "id": "ptcpt-join-6497c79b-f326-4942-aef8-c36a29140ad1-1708972279961", - "payload": { - "room": "test", - "user_id": "6497c79b-f326-4942-aef8-c36a29140ad1", - "user_name": "testuser", - "session_id": "0c0d2dda-f21d-4cf9-ab56-86bf3c407ffa", - "joined_at": 1708972279.96, - "will_eject_at": 1708972299.541, - "owner": false, - "permissions": { - "hasPresence": true, - "canSend": true, - "canReceive": { "base": true }, - "canAdmin": false - } - }, - "event_ts": 1708972279.961 -} - -""" - - -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 not meeting: - logger.warning( - "participant.joined: meeting not found", room_name=daily_room_name - ) - return - - payload = event.payload - joined_at = datetime.fromtimestamp(payload["joined_at"], tz=timezone.utc) - session_id = f"{meeting.id}:{payload['session_id']}" - - session = DailyParticipantSession( - id=session_id, - meeting_id=meeting.id, - room_id=meeting.room_id, - session_id=payload["session_id"], - user_id=payload.get("user_id", None), - user_name=payload["user_name"], - joined_at=joined_at, - left_at=None, - ) - - # num_clients serves as a projection/cache of active session count for Daily.co - # Both operations must succeed or fail together to maintain consistency - async with get_database().transaction(): - await meetings_controller.increment_num_clients(meeting.id) - await daily_participant_sessions_controller.upsert_joined(session) - - logger.info( - "Participant joined", - meeting_id=meeting.id, - room_name=daily_room_name, - user_id=payload.get("user_id", None), - user_name=payload.get("user_name"), - session_id=session_id, - ) - - -""" -{ - "version": "1.0.0", - "type": "participant.left", - "id": "ptcpt-left-16168c97-f973-4eae-9642-020fe3fda5db-1708972302986", - "payload": { - "room": "test", - "user_id": "16168c97-f973-4eae-9642-020fe3fda5db", - "user_name": "bipol", - "session_id": "0c0d2dda-f21d-4cf9-ab56-86bf3c407ffa", - "joined_at": 1708972291.567, - "will_eject_at": null, - "owner": false, - "permissions": { - "hasPresence": true, - "canSend": true, - "canReceive": { "base": true }, - "canAdmin": false - }, - "duration": 11.419000148773193 - }, - "event_ts": 1708972302.986 -} -""" - - -async def _handle_participant_left(event: DailyWebhookEvent): - room_name = extract_room_name(event) +async def _queue_poll_for_room( + room_name: str | None, + event_type: str, + user_id: str | None, + session_id: str | None, + **log_kwargs, +) -> None: + """Queue poll task for room by name, handling missing room/meeting cases.""" if not room_name: - logger.warning("participant.left: no room in payload", payload=event.payload) + logger.warning(f"{event_type}: no room in payload") 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) + logger.warning(f"{event_type}: meeting not found", room_name=room_name) return - payload = event.payload - joined_at = datetime.fromtimestamp(payload["joined_at"], tz=timezone.utc) - left_at = datetime.fromtimestamp(event.event_ts, tz=timezone.utc) - session_id = f"{meeting.id}:{payload['session_id']}" - - session = DailyParticipantSession( - id=session_id, - meeting_id=meeting.id, - room_id=meeting.room_id, - session_id=payload["session_id"], - user_id=payload.get("user_id", None), - user_name=payload["user_name"], - joined_at=joined_at, - left_at=left_at, - ) - - # num_clients serves as a projection/cache of active session count for Daily.co - # Both operations must succeed or fail together to maintain consistency - async with get_database().transaction(): - await meetings_controller.decrement_num_clients(meeting.id) - await daily_participant_sessions_controller.upsert_left(session) + poll_daily_room_presence_task.delay(meeting.id) logger.info( - "Participant left", + f"{event_type.replace('.', ' ').title()} - poll queued", meeting_id=meeting.id, room_name=room_name, - user_id=payload.get("user_id", None), - duration=payload.get("duration"), + user_id=user_id, session_id=session_id, + **log_kwargs, ) -async def _handle_recording_started(event: DailyWebhookEvent): - room_name = extract_room_name(event) +async def _handle_participant_joined(event: ParticipantJoinedEvent): + """Queue poll task for presence reconciliation.""" + await _queue_poll_for_room( + event.payload.room_name, + "participant.joined", + event.payload.user_id, + event.payload.session_id, + user_name=event.payload.user_name, + ) + + +async def _handle_participant_left(event: ParticipantLeftEvent): + """Queue poll task for presence reconciliation.""" + await _queue_poll_for_room( + event.payload.room_name, + "participant.left", + event.payload.user_id, + event.payload.session_id, + duration=event.payload.duration, + ) + + +async def _handle_recording_started(event: RecordingStartedEvent): + room_name = event.payload.room_name if not room_name: logger.warning( "recording.started: no room_name in payload", payload=event.payload @@ -253,49 +164,27 @@ async def _handle_recording_started(event: DailyWebhookEvent): "Recording started", meeting_id=meeting.id, room_name=room_name, - recording_id=event.payload.get("recording_id"), + recording_id=event.payload.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. +async def _handle_recording_ready(event: RecordingReadyEvent): + room_name = event.payload.room_name + recording_id = event.payload.recording_id + tracks = event.payload.tracks - 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: + if not tracks: logger.warning( - "recording.ready-to-download: missing room_name or tracks", + "recording.ready-to-download: missing tracks", room_name=room_name, - has_tracks=bool(tracks_raw), + recording_id=recording_id, 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, @@ -313,6 +202,12 @@ async def _handle_recording_ready(event: DailyWebhookEvent): track_keys = [t.s3Key for t in tracks if t.type == "audio"] + logger.info( + "Recording webhook queuing processing", + recording_id=recording_id, + room_name=room_name, + ) + process_multitrack_recording.delay( bucket_name=bucket_name, daily_room_name=room_name, @@ -321,17 +216,18 @@ async def _handle_recording_ready(event: DailyWebhookEvent): ) -async def _handle_recording_error(event: DailyWebhookEvent): - payload = parse_recording_error(event) +async def _handle_recording_error(event: RecordingErrorEvent): + payload = event.payload room_name = payload.room_name - 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=payload.error_msg, - platform="daily", - ) + 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=payload.error_msg, + platform="daily", + ) + else: + logger.warning("recording.error: meeting not found", room_name=room_name) diff --git a/server/reflector/worker/app.py b/server/reflector/worker/app.py index 3c7795a2..c0e711ae 100644 --- a/server/reflector/worker/app.py +++ b/server/reflector/worker/app.py @@ -38,6 +38,14 @@ else: "task": "reflector.worker.process.reprocess_failed_recordings", "schedule": crontab(hour=5, minute=0), # Midnight EST }, + "poll_daily_recordings": { + "task": "reflector.worker.process.poll_daily_recordings", + "schedule": 180.0, # Every 3 minutes (configurable lookback window) + }, + "trigger_daily_reconciliation": { + "task": "reflector.worker.process.trigger_daily_reconciliation", + "schedule": 30.0, # Every 30 seconds (queues poll tasks for all active meetings) + }, "sync_all_ics_calendars": { "task": "reflector.worker.ics_sync.sync_all_ics_calendars", "schedule": 60.0, # Run every minute to check which rooms need sync diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index dd9c1059..0e1b4d86 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -10,8 +10,12 @@ import structlog from celery import shared_task from celery.utils.log import get_task_logger from pydantic import ValidationError -from redis.exceptions import LockError +from reflector.dailyco_api import MeetingParticipantsResponse +from reflector.db.daily_participant_sessions import ( + DailyParticipantSession, + daily_participant_sessions_controller, +) from reflector.db.meetings import meetings_controller from reflector.db.recordings import Recording, recordings_controller from reflector.db.rooms import rooms_controller @@ -28,10 +32,15 @@ from reflector.pipelines.main_multitrack_pipeline import ( from reflector.pipelines.topic_processing import EmptyPipeline from reflector.processors import AudioFileWriterProcessor from reflector.processors.audio_waveform_processor import AudioWaveformProcessor -from reflector.redis_cache import get_redis_client +from reflector.redis_cache import RedisAsyncLock from reflector.settings import settings from reflector.storage import get_transcripts_storage -from reflector.utils.daily import DailyRoomName, extract_base_room_name +from reflector.utils.daily import ( + DailyRoomName, + extract_base_room_name, + parse_daily_recording_filename, + recording_lock_key, +) from reflector.video_platforms.factory import create_platform_client from reflector.video_platforms.whereby_utils import ( parse_whereby_recording_filename, @@ -178,6 +187,42 @@ async def process_multitrack_recording( logger.warning("No audio track keys provided") return + lock_key = recording_lock_key(recording_id) + async with RedisAsyncLock( + key=lock_key, + timeout=600, # 10min for processing (includes API calls, DB writes) + extend_interval=60, # Auto-extend every 60s + skip_if_locked=True, + blocking=False, + ) as lock: + if not lock.acquired: + logger.warning( + "Recording processing skipped - lock already held (duplicate task or concurrent worker)", + recording_id=recording_id, + lock_key=lock_key, + reason="duplicate_task_or_concurrent_worker", + ) + return + + logger.info( + "Recording worker acquired lock - starting processing", + recording_id=recording_id, + lock_key=lock_key, + ) + + await _process_multitrack_recording_inner( + bucket_name, daily_room_name, recording_id, track_keys + ) + + +async def _process_multitrack_recording_inner( + bucket_name: str, + daily_room_name: DailyRoomName, + recording_id: str, + track_keys: list[str], +): + """Inner function containing the actual processing logic.""" + tz = timezone.utc recorded_at = datetime.now(tz) try: @@ -225,9 +270,7 @@ async def process_multitrack_recording( track_keys=track_keys, ) ) - else: - # Recording already exists; assume metadata was set at creation time - pass + # else: Recording already exists; metadata set at creation time transcript = await transcripts_controller.get_by_recording_id(recording.id) if transcript: @@ -252,60 +295,70 @@ async def process_multitrack_recording( ) try: - daily_client = create_platform_client("daily") + async with create_platform_client("daily") as daily_client: + id_to_name = {} + id_to_user_id = {} - id_to_name = {} - id_to_user_id = {} - - mtg_session_id = None - try: - rec_details = await daily_client.get_recording(recording_id) - mtg_session_id = rec_details.get("mtgSessionId") - except Exception as e: - logger.warning( - "Failed to fetch Daily recording details", - error=str(e), - recording_id=recording_id, - exc_info=True, - ) - - if mtg_session_id: try: - payload = await daily_client.get_meeting_participants(mtg_session_id) - for p in payload.get("data", []): - pid = p.get("participant_id") - name = p.get("user_name") - user_id = p.get("user_id") - if pid and name: - id_to_name[pid] = name - if pid and user_id: - id_to_user_id[pid] = user_id + rec_details = await daily_client.get_recording(recording_id) + mtg_session_id = rec_details.mtgSessionId + if mtg_session_id: + try: + payload: MeetingParticipantsResponse = ( + await daily_client.get_meeting_participants(mtg_session_id) + ) + for p in payload.data: + pid = p.participant_id + assert ( + pid is not None + ), "panic! participant id cannot be None" + name = p.user_name + user_id = p.user_id + if name: + id_to_name[pid] = name + if user_id: + id_to_user_id[pid] = user_id + except Exception as e: + logger.warning( + "Failed to fetch Daily meeting participants", + error=str(e), + mtg_session_id=mtg_session_id, + exc_info=True, + ) + else: + logger.warning( + "No mtgSessionId found for recording; participant names may be generic", + recording_id=recording_id, + ) except Exception as e: logger.warning( - "Failed to fetch Daily meeting participants", + "Failed to fetch Daily recording details", error=str(e), - mtg_session_id=mtg_session_id, + recording_id=recording_id, exc_info=True, ) - else: - logger.warning( - "No mtgSessionId found for recording; participant names may be generic", - recording_id=recording_id, - ) - for idx, key in enumerate(track_keys): - base = os.path.basename(key) - m = re.search(r"\d{13,}-([0-9a-fA-F-]{36})-cam-audio-", base) - participant_id = m.group(1) if m else None + for idx, key in enumerate(track_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, + exc_info=True, + ) + continue - default_name = f"Speaker {idx}" - name = id_to_name.get(participant_id, default_name) - user_id = id_to_user_id.get(participant_id) + 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) + participant = TranscriptParticipant( + id=participant_id, speaker=idx, name=name, user_id=user_id + ) + await transcripts_controller.upsert_participant(transcript, participant) except Exception as e: logger.warning("Failed to map participant names", error=str(e), exc_info=True) @@ -317,6 +370,207 @@ async def process_multitrack_recording( ) +@shared_task +@asynctask +async def poll_daily_recordings(): + """Poll Daily.co API for recordings and process missing ones. + + Fetches latest recordings from Daily.co API (default limit 100), compares with DB, + and queues processing for recordings not already in DB. + + For each missing recording, uses audio tracks from API response. + + Worker-level locking provides idempotency (see process_multitrack_recording). + """ + bucket_name = settings.DAILYCO_STORAGE_AWS_BUCKET_NAME + if not bucket_name: + logger.debug( + "DAILYCO_STORAGE_AWS_BUCKET_NAME not configured; skipping recording poll" + ) + return + + async with create_platform_client("daily") as daily_client: + # latest 100. TODO cursor-based state + api_recordings = await daily_client.list_recordings() + + if not api_recordings: + logger.debug( + "No recordings found from Daily.co API", + ) + return + + recording_ids = [rec.id for rec in api_recordings] + existing_recordings = await recordings_controller.get_by_ids(recording_ids) + existing_ids = {rec.id for rec in existing_recordings} + + missing_recordings = [rec for rec in api_recordings if rec.id not in existing_ids] + + if not missing_recordings: + logger.debug( + "All recordings already in DB", + api_count=len(api_recordings), + existing_count=len(existing_recordings), + ) + return + + logger.info( + "Found recordings missing from DB", + missing_count=len(missing_recordings), + total_api_count=len(api_recordings), + existing_count=len(existing_recordings), + ) + + for recording in missing_recordings: + if not recording.tracks: + assert recording.status != "finished", ( + f"Recording {recording.id} has status='finished' but no tracks. " + f"Daily.co API guarantees finished recordings have tracks available. " + f"room_name={recording.room_name}" + ) + logger.debug( + "No tracks in recording yet", + recording_id=recording.id, + room_name=recording.room_name, + status=recording.status, + ) + continue + + track_keys = [t.s3Key for t in recording.tracks if t.type == "audio"] + + if not track_keys: + logger.warning( + "No audio tracks found in recording (only video tracks)", + recording_id=recording.id, + room_name=recording.room_name, + total_tracks=len(recording.tracks), + ) + continue + + logger.info( + "Queueing missing recording for processing", + recording_id=recording.id, + room_name=recording.room_name, + track_count=len(track_keys), + ) + + process_multitrack_recording.delay( + bucket_name=bucket_name, + daily_room_name=recording.room_name, + recording_id=recording.id, + track_keys=track_keys, + ) + + +async def poll_daily_room_presence(meeting_id: str) -> None: + """Poll Daily.co room presence and reconcile with DB sessions. New presence is added, old presence is marked as closed. + Warning: Daily api returns only current state, so there could be missed presence updates, people who went and left the room quickly. + Therefore, set(presences) != set(recordings) even if everyone said something. This is not a problem but should be noted.""" + + async with RedisAsyncLock( + key=f"meeting_presence_poll:{meeting_id}", + timeout=120, + extend_interval=30, + skip_if_locked=True, + blocking=False, + ) as lock: + if not lock.acquired: + logger.debug( + "Concurrent poll skipped (duplicate task)", meeting_id=meeting_id + ) + return + + meeting = await meetings_controller.get_by_id(meeting_id) + if not meeting: + logger.warning("Meeting not found", meeting_id=meeting_id) + return + + async with create_platform_client("daily") as daily_client: + try: + presence = await daily_client.get_room_presence(meeting.room_name) + except Exception as e: + logger.error( + "Daily.co API fetch failed", + meeting_id=meeting.id, + room_name=meeting.room_name, + error=str(e), + exc_info=True, + ) + return + + api_participants = {p.id: p for p in presence.data} + db_sessions = ( + await daily_participant_sessions_controller.get_all_sessions_for_meeting( + meeting.id + ) + ) + + active_session_ids = { + sid for sid, s in db_sessions.items() if s.left_at is None + } + missing_session_ids = set(api_participants.keys()) - active_session_ids + stale_session_ids = active_session_ids - set(api_participants.keys()) + + if missing_session_ids: + missing_sessions = [] + for session_id in missing_session_ids: + p = api_participants[session_id] + session = DailyParticipantSession( + id=f"{meeting.id}:{session_id}", + meeting_id=meeting.id, + room_id=meeting.room_id, + session_id=session_id, + user_id=p.userId, + user_name=p.userName, + joined_at=datetime.fromisoformat(p.joinTime), + left_at=None, + ) + missing_sessions.append(session) + + await daily_participant_sessions_controller.batch_upsert_sessions( + missing_sessions + ) + logger.info( + "Sessions added", + meeting_id=meeting.id, + count=len(missing_sessions), + ) + + if stale_session_ids: + composite_ids = [f"{meeting.id}:{sid}" for sid in stale_session_ids] + await daily_participant_sessions_controller.batch_close_sessions( + composite_ids, + left_at=datetime.now(timezone.utc), + ) + logger.info( + "Stale sessions closed", + meeting_id=meeting.id, + count=len(composite_ids), + ) + + final_active_count = len(api_participants) + if meeting.num_clients != final_active_count: + await meetings_controller.update_meeting( + meeting.id, + num_clients=final_active_count, + ) + logger.info( + "num_clients updated", + meeting_id=meeting.id, + old_value=meeting.num_clients, + new_value=final_active_count, + ) + + +@shared_task +@asynctask +async def poll_daily_room_presence_task(meeting_id: str) -> None: + """Celery task wrapper for poll_daily_room_presence. + + Queued by webhooks or reconciliation timer. + """ + await poll_daily_room_presence(meeting_id) + + @shared_task @asynctask async def process_meetings(): @@ -335,74 +589,71 @@ async def process_meetings(): Uses distributed locking to prevent race conditions when multiple workers process the same meeting simultaneously. """ + meetings = await meetings_controller.get_all_active() logger.info(f"Processing {len(meetings)} meetings") current_time = datetime.now(timezone.utc) - redis_client = get_redis_client() processed_count = 0 skipped_count = 0 for meeting in meetings: logger_ = logger.bind(meeting_id=meeting.id, room_name=meeting.room_name) logger_.info("Processing meeting") - lock_key = f"meeting_process_lock:{meeting.id}" - lock = redis_client.lock(lock_key, timeout=120) try: - if not lock.acquire(blocking=False): - logger_.debug("Meeting is being processed by another worker, skipping") - skipped_count += 1 - continue + async with RedisAsyncLock( + key=f"meeting_process_lock:{meeting.id}", + timeout=120, + extend_interval=30, + skip_if_locked=True, + blocking=False, + ) as lock: + if not lock.acquired: + logger_.debug( + "Meeting is being processed by another worker, skipping" + ) + skipped_count += 1 + continue - # Process the meeting - should_deactivate = False - end_date = meeting.end_date - if end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=timezone.utc) + # Process the meeting + should_deactivate = False + end_date = meeting.end_date + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) - client = create_platform_client(meeting.platform) - room_sessions = await client.get_room_sessions(meeting.room_name) + client = create_platform_client(meeting.platform) + room_sessions = await client.get_room_sessions(meeting.room_name) - try: - # Extend lock after operation to ensure we still hold it - lock.extend(120, replace_ttl=True) - except LockError: - logger_.warning("Lost lock for meeting, skipping") - continue - - has_active_sessions = room_sessions and any( - s.ended_at is None for s in room_sessions - ) - has_had_sessions = bool(room_sessions) - logger_.info( - f"found {has_active_sessions} active sessions, had {has_had_sessions}" - ) - - if has_active_sessions: - logger_.debug("Meeting still has active sessions, keep it") - elif has_had_sessions: - should_deactivate = True - logger_.info("Meeting ended - all participants left") - elif current_time > end_date: - should_deactivate = True - logger_.info( - "Meeting deactivated - scheduled time ended with no participants", + has_active_sessions = room_sessions and any( + s.ended_at is None for s in room_sessions + ) + has_had_sessions = bool(room_sessions) + logger_.info( + f"found {has_active_sessions} active sessions, had {has_had_sessions}" ) - else: - logger_.debug("Meeting not yet started, keep it") - if should_deactivate: - await meetings_controller.update_meeting(meeting.id, is_active=False) - logger_.info("Meeting is deactivated") + if has_active_sessions: + logger_.debug("Meeting still has active sessions, keep it") + elif has_had_sessions: + should_deactivate = True + logger_.info("Meeting ended - all participants left") + elif current_time > end_date: + should_deactivate = True + logger_.info( + "Meeting deactivated - scheduled time ended with no participants", + ) + else: + logger_.debug("Meeting not yet started, keep it") - processed_count += 1 + if should_deactivate: + await meetings_controller.update_meeting( + meeting.id, is_active=False + ) + logger_.info("Meeting is deactivated") + + processed_count += 1 except Exception: logger_.error("Error processing meeting", exc_info=True) - finally: - try: - lock.release() - except LockError: - pass # Lock already released or expired logger.debug( "Processed meetings finished", @@ -524,3 +775,34 @@ async def reprocess_failed_recordings(): logger.info(f"Reprocessing complete. Requeued {reprocessed_count} recordings") return reprocessed_count + + +@shared_task +@asynctask +async def trigger_daily_reconciliation() -> None: + """Daily.co pull""" + try: + active_meetings = await meetings_controller.get_all_active(platform="daily") + queued_count = 0 + + for meeting in active_meetings: + try: + poll_daily_room_presence_task.delay(meeting.id) + queued_count += 1 + except Exception as e: + logger.error( + "Failed to queue reconciliation poll", + meeting_id=meeting.id, + error=str(e), + exc_info=True, + ) + raise + + if queued_count > 0: + logger.debug( + "Reconciliation polls queued", + count=queued_count, + ) + + except Exception as e: + logger.error("Reconciliation trigger failed", error=str(e), exc_info=True) diff --git a/server/tests/mocks/mock_platform.py b/server/tests/mocks/mock_platform.py index b4d9ae90..cb0cba5e 100644 --- a/server/tests/mocks/mock_platform.py +++ b/server/tests/mocks/mock_platform.py @@ -64,12 +64,6 @@ class MockPlatformClient(VideoPlatformClient): ) ] - async def delete_room(self, room_name: str) -> bool: - if room_name in self._rooms: - self._rooms[room_name]["is_active"] = False - return True - return False - async def upload_logo(self, room_name: str, logo_path: str) -> bool: if room_name in self._rooms: self._rooms[room_name]["logo_path"] = logo_path diff --git a/server/tests/test_daily_room_presence_polling.py b/server/tests/test_daily_room_presence_polling.py new file mode 100644 index 00000000..19398a22 --- /dev/null +++ b/server/tests/test_daily_room_presence_polling.py @@ -0,0 +1,466 @@ +"""Tests for Daily.co room presence polling functionality. + +TDD tests for Task 3.2: Room Presence Polling +- Query Daily.co API for current room participants +- Reconcile with DB sessions (add missing, close stale) +- Update meeting.num_clients if different +- Use batch operations for efficiency +""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from reflector.dailyco_api.responses import ( + RoomPresenceParticipant, + RoomPresenceResponse, +) +from reflector.db.daily_participant_sessions import DailyParticipantSession +from reflector.db.meetings import Meeting +from reflector.worker.process import poll_daily_room_presence + + +@pytest.fixture +def mock_meeting(): + """Mock meeting with Daily.co room.""" + return Meeting( + id="meeting-123", + room_id="room-456", + room_name="test-room-20251118120000", + room_url="https://daily.co/test-room-20251118120000", + host_room_url="https://daily.co/test-room-20251118120000?t=host-token", + platform="daily", + num_clients=2, + is_active=True, + start_date=datetime.now(timezone.utc), + end_date=datetime.now(timezone.utc), + ) + + +@pytest.fixture +def mock_api_participants(): + """Mock Daily.co API presence response.""" + now = datetime.now(timezone.utc) + return RoomPresenceResponse( + total_count=2, + data=[ + RoomPresenceParticipant( + room="test-room-20251118120000", + id="participant-1", + userName="Alice", + userId="user-alice", + joinTime=(now - timedelta(minutes=10)).isoformat(), + duration=600, + ), + RoomPresenceParticipant( + room="test-room-20251118120000", + id="participant-2", + userName="Bob", + userId="user-bob", + joinTime=(now - timedelta(minutes=5)).isoformat(), + duration=300, + ), + ], + ) + + +@pytest.mark.asyncio +@patch("reflector.worker.process.meetings_controller.get_by_id") +@patch("reflector.worker.process.create_platform_client") +@patch( + "reflector.worker.process.daily_participant_sessions_controller.get_all_sessions_for_meeting" +) +@patch( + "reflector.worker.process.daily_participant_sessions_controller.batch_upsert_sessions" +) +async def test_poll_presence_adds_missing_sessions( + mock_batch_upsert, + mock_get_sessions, + mock_create_client, + mock_get_by_id, + mock_meeting, + mock_api_participants, +): + """Test that polling creates sessions for participants not in DB.""" + mock_get_by_id.return_value = mock_meeting + + mock_daily_client = AsyncMock() + mock_daily_client.get_room_presence = AsyncMock(return_value=mock_api_participants) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + mock_get_sessions.return_value = {} + mock_batch_upsert.return_value = None + + await poll_daily_room_presence(mock_meeting.id) + + assert mock_batch_upsert.call_count == 1 + sessions = mock_batch_upsert.call_args.args[0] + assert len(sessions) == 2 + session_ids = {s.session_id for s in sessions} + assert session_ids == {"participant-1", "participant-2"} + + +@pytest.mark.asyncio +@patch("reflector.worker.process.meetings_controller.get_by_id") +@patch("reflector.worker.process.create_platform_client") +@patch( + "reflector.worker.process.daily_participant_sessions_controller.get_all_sessions_for_meeting" +) +@patch( + "reflector.worker.process.daily_participant_sessions_controller.batch_upsert_sessions" +) +@patch( + "reflector.worker.process.daily_participant_sessions_controller.batch_close_sessions" +) +async def test_poll_presence_closes_stale_sessions( + mock_batch_close, + mock_batch_upsert, + mock_get_sessions, + mock_create_client, + mock_get_by_id, + mock_meeting, + mock_api_participants, +): + """Test that polling closes sessions for participants no longer in room.""" + mock_get_by_id.return_value = mock_meeting + + mock_daily_client = AsyncMock() + mock_daily_client.get_room_presence = AsyncMock(return_value=mock_api_participants) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + now = datetime.now(timezone.utc) + mock_get_sessions.return_value = { + "participant-1": DailyParticipantSession( + id=f"meeting-123:participant-1", + meeting_id="meeting-123", + room_id="room-456", + session_id="participant-1", + user_id="user-alice", + user_name="Alice", + joined_at=now, + left_at=None, + ), + "participant-stale": DailyParticipantSession( + id=f"meeting-123:participant-stale", + meeting_id="meeting-123", + room_id="room-456", + session_id="participant-stale", + user_id="user-stale", + user_name="Stale User", + joined_at=now - timedelta(seconds=120), # Joined 2 minutes ago + left_at=None, + ), + } + + await poll_daily_room_presence(mock_meeting.id) + + assert mock_batch_close.call_count == 1 + composite_ids = mock_batch_close.call_args.args[0] + left_at = mock_batch_close.call_args.kwargs["left_at"] + assert len(composite_ids) == 1 + assert "meeting-123:participant-stale" in composite_ids + assert left_at is not None + + +@pytest.mark.asyncio +@patch("reflector.worker.process.meetings_controller.get_by_id") +@patch("reflector.worker.process.create_platform_client") +@patch( + "reflector.worker.process.daily_participant_sessions_controller.get_all_sessions_for_meeting" +) +@patch( + "reflector.worker.process.daily_participant_sessions_controller.batch_upsert_sessions" +) +@patch("reflector.worker.process.meetings_controller.update_meeting") +async def test_poll_presence_updates_num_clients( + mock_update_meeting, + mock_batch_upsert, + mock_get_sessions, + mock_create_client, + mock_get_by_id, + mock_meeting, + mock_api_participants, +): + """Test that polling updates num_clients when different from API.""" + meeting_with_wrong_count = mock_meeting + meeting_with_wrong_count.num_clients = 5 + mock_get_by_id.return_value = meeting_with_wrong_count + + mock_daily_client = AsyncMock() + mock_daily_client.get_room_presence = AsyncMock(return_value=mock_api_participants) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + mock_get_sessions.return_value = {} + mock_batch_upsert.return_value = None + + await poll_daily_room_presence(meeting_with_wrong_count.id) + + assert mock_update_meeting.call_count == 1 + assert mock_update_meeting.call_args.kwargs["num_clients"] == 2 + + +@pytest.mark.asyncio +@patch("reflector.worker.process.meetings_controller.get_by_id") +@patch("reflector.worker.process.create_platform_client") +@patch( + "reflector.worker.process.daily_participant_sessions_controller.get_all_sessions_for_meeting" +) +async def test_poll_presence_no_changes_if_synced( + mock_get_sessions, + mock_create_client, + mock_get_by_id, + mock_meeting, + mock_api_participants, +): + """Test that polling skips updates when DB already synced with API.""" + mock_get_by_id.return_value = mock_meeting + + mock_daily_client = AsyncMock() + mock_daily_client.get_room_presence = AsyncMock(return_value=mock_api_participants) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + now = datetime.now(timezone.utc) + mock_get_sessions.return_value = { + "participant-1": DailyParticipantSession( + id=f"meeting-123:participant-1", + meeting_id="meeting-123", + room_id="room-456", + session_id="participant-1", + user_id="user-alice", + user_name="Alice", + joined_at=now, + left_at=None, + ), + "participant-2": DailyParticipantSession( + id=f"meeting-123:participant-2", + meeting_id="meeting-123", + room_id="room-456", + session_id="participant-2", + user_id="user-bob", + user_name="Bob", + joined_at=now, + left_at=None, + ), + } + + await poll_daily_room_presence(mock_meeting.id) + + +@pytest.mark.asyncio +@patch("reflector.worker.process.meetings_controller.get_by_id") +@patch("reflector.worker.process.create_platform_client") +@patch( + "reflector.worker.process.daily_participant_sessions_controller.get_all_sessions_for_meeting" +) +@patch( + "reflector.worker.process.daily_participant_sessions_controller.batch_upsert_sessions" +) +@patch( + "reflector.worker.process.daily_participant_sessions_controller.batch_close_sessions" +) +async def test_poll_presence_mixed_add_and_remove( + mock_batch_close, + mock_batch_upsert, + mock_get_sessions, + mock_create_client, + mock_get_by_id, + mock_meeting, +): + """Test that polling handles simultaneous joins and leaves in single poll.""" + mock_get_by_id.return_value = mock_meeting + + now = datetime.now(timezone.utc) + + # API returns: participant-1 and participant-3 (new) + api_response = RoomPresenceResponse( + total_count=2, + data=[ + RoomPresenceParticipant( + room="test-room-20251118120000", + id="participant-1", + userName="Alice", + userId="user-alice", + joinTime=(now - timedelta(minutes=10)).isoformat(), + duration=600, + ), + RoomPresenceParticipant( + room="test-room-20251118120000", + id="participant-3", + userName="Charlie", + userId="user-charlie", + joinTime=now.isoformat(), + duration=0, + ), + ], + ) + + mock_daily_client = AsyncMock() + mock_daily_client.get_room_presence = AsyncMock(return_value=api_response) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + # DB has: participant-1 and participant-2 (left but not in API) + mock_get_sessions.return_value = { + "participant-1": DailyParticipantSession( + id=f"meeting-123:participant-1", + meeting_id="meeting-123", + room_id="room-456", + session_id="participant-1", + user_id="user-alice", + user_name="Alice", + joined_at=now - timedelta(minutes=10), + left_at=None, + ), + "participant-2": DailyParticipantSession( + id=f"meeting-123:participant-2", + meeting_id="meeting-123", + room_id="room-456", + session_id="participant-2", + user_id="user-bob", + user_name="Bob", + joined_at=now - timedelta(minutes=5), + left_at=None, + ), + } + + mock_batch_upsert.return_value = None + mock_batch_close.return_value = None + + await poll_daily_room_presence(mock_meeting.id) + + # Verify participant-3 was added (missing in DB) + assert mock_batch_upsert.call_count == 1 + sessions_added = mock_batch_upsert.call_args.args[0] + assert len(sessions_added) == 1 + assert sessions_added[0].session_id == "participant-3" + assert sessions_added[0].user_name == "Charlie" + + # Verify participant-2 was closed (stale in DB) + assert mock_batch_close.call_count == 1 + composite_ids = mock_batch_close.call_args.args[0] + assert len(composite_ids) == 1 + assert "meeting-123:participant-2" in composite_ids + + +@pytest.mark.asyncio +@patch("reflector.worker.process.meetings_controller.get_by_id") +@patch("reflector.worker.process.create_platform_client") +async def test_poll_presence_handles_api_error( + mock_create_client, + mock_get_by_id, + mock_meeting, +): + """Test that polling handles Daily.co API errors gracefully.""" + mock_get_by_id.return_value = mock_meeting + + mock_daily_client = AsyncMock() + mock_daily_client.get_room_presence = AsyncMock(side_effect=Exception("API error")) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + await poll_daily_room_presence(mock_meeting.id) + + +@pytest.mark.asyncio +@patch("reflector.worker.process.meetings_controller.get_by_id") +@patch("reflector.worker.process.create_platform_client") +@patch( + "reflector.worker.process.daily_participant_sessions_controller.get_all_sessions_for_meeting" +) +@patch( + "reflector.worker.process.daily_participant_sessions_controller.batch_close_sessions" +) +async def test_poll_presence_closes_all_when_room_empty( + mock_batch_close, + mock_get_sessions, + mock_create_client, + mock_get_by_id, + mock_meeting, +): + """Test that polling closes all sessions when room is empty.""" + mock_get_by_id.return_value = mock_meeting + + mock_daily_client = AsyncMock() + mock_daily_client.get_room_presence = AsyncMock( + return_value=RoomPresenceResponse(total_count=0, data=[]) + ) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + now = datetime.now(timezone.utc) + mock_get_sessions.return_value = { + "participant-1": DailyParticipantSession( + id=f"meeting-123:participant-1", + meeting_id="meeting-123", + room_id="room-456", + session_id="participant-1", + user_id="user-alice", + user_name="Alice", + joined_at=now + - timedelta(seconds=120), # Joined 2 minutes ago (beyond grace period) + left_at=None, + ), + } + + await poll_daily_room_presence(mock_meeting.id) + + assert mock_batch_close.call_count == 1 + composite_ids = mock_batch_close.call_args.args[0] + left_at = mock_batch_close.call_args.kwargs["left_at"] + assert len(composite_ids) == 1 + assert "meeting-123:participant-1" in composite_ids + assert left_at is not None + + +@pytest.mark.asyncio +@patch("reflector.worker.process.RedisAsyncLock") +@patch("reflector.worker.process.meetings_controller.get_by_id") +@patch("reflector.worker.process.create_platform_client") +async def test_poll_presence_skips_if_locked( + mock_create_client, + mock_get_by_id, + mock_redis_lock_class, + mock_meeting, +): + """Test that concurrent polling is prevented by Redis lock.""" + mock_get_by_id.return_value = mock_meeting + + # Mock the RedisAsyncLock to simulate lock not acquired + mock_lock_instance = AsyncMock() + mock_lock_instance.acquired = False # Lock not acquired + mock_lock_instance.__aenter__ = AsyncMock(return_value=mock_lock_instance) + mock_lock_instance.__aexit__ = AsyncMock() + + mock_redis_lock_class.return_value = mock_lock_instance + + mock_daily_client = AsyncMock() + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + await poll_daily_room_presence(mock_meeting.id) + + # Verify RedisAsyncLock was instantiated + assert mock_redis_lock_class.call_count == 1 + # Verify get_room_presence was NOT called (lock not acquired, so function returned early) + assert mock_daily_client.get_room_presence.call_count == 0 diff --git a/server/tests/test_poll_daily_recordings.py b/server/tests/test_poll_daily_recordings.py new file mode 100644 index 00000000..acbbfcc7 --- /dev/null +++ b/server/tests/test_poll_daily_recordings.py @@ -0,0 +1,193 @@ +"""Tests for poll_daily_recordings task.""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest + +from reflector.dailyco_api.responses import RecordingResponse +from reflector.dailyco_api.webhooks import DailyTrack + + +# Import the unwrapped async function for testing +# The function is decorated with @shared_task and @asynctask, +# but we need to test the underlying async implementation +def _get_poll_daily_recordings_fn(): + """Get the underlying async function without Celery/asynctask decorators.""" + from reflector.worker import process + + # Access the actual async function before decorators + fn = process.poll_daily_recordings + # Get through both decorator layers + if hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + if hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + return fn + + +@pytest.fixture +def mock_recording_response(): + """Mock Daily.co API recording response with tracks.""" + now = datetime.now(timezone.utc) + return [ + RecordingResponse( + id="rec-123", + room_name="test-room-20251118120000", + start_ts=int((now - timedelta(hours=1)).timestamp()), + status="finished", + max_participants=2, + duration=3600, + share_token="share-token-123", + tracks=[ + DailyTrack(type="audio", s3Key="track1.webm", size=1024), + DailyTrack(type="audio", s3Key="track2.webm", size=2048), + ], + ), + RecordingResponse( + id="rec-456", + room_name="test-room-20251118130000", + start_ts=int((now - timedelta(hours=2)).timestamp()), + status="finished", + max_participants=3, + duration=7200, + share_token="share-token-456", + tracks=[ + DailyTrack(type="audio", s3Key="track1.webm", size=1024), + ], + ), + ] + + +@pytest.mark.asyncio +@patch("reflector.worker.process.settings") +@patch("reflector.worker.process.create_platform_client") +@patch("reflector.worker.process.recordings_controller.get_by_ids") +@patch("reflector.worker.process.process_multitrack_recording.delay") +async def test_poll_daily_recordings_processes_missing_recordings( + mock_process_delay, + mock_get_recordings, + mock_create_client, + mock_settings, + mock_recording_response, +): + """Test that poll_daily_recordings queues processing for recordings not in DB.""" + mock_settings.DAILYCO_STORAGE_AWS_BUCKET_NAME = "test-bucket" + + # Mock Daily.co API client + mock_daily_client = AsyncMock() + mock_daily_client.list_recordings = AsyncMock(return_value=mock_recording_response) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + # Mock DB controller - no existing recordings + mock_get_recordings.return_value = [] + + # Execute - call the unwrapped async function + poll_fn = _get_poll_daily_recordings_fn() + await poll_fn() + + # Verify Daily.co API was called without time parameters (uses default limit=100) + assert mock_daily_client.list_recordings.call_count == 1 + call_kwargs = mock_daily_client.list_recordings.call_args.kwargs + + # Should not have time-based parameters (uses cursor-based pagination) + assert "start_time" not in call_kwargs + assert "end_time" not in call_kwargs + + # Verify processing was queued for both missing recordings + assert mock_process_delay.call_count == 2 + + # Verify the processing calls have correct parameters + calls = mock_process_delay.call_args_list + assert calls[0].kwargs["bucket_name"] == "test-bucket" + assert calls[0].kwargs["recording_id"] == "rec-123" + assert calls[0].kwargs["daily_room_name"] == "test-room-20251118120000" + assert calls[0].kwargs["track_keys"] == ["track1.webm", "track2.webm"] + + assert calls[1].kwargs["bucket_name"] == "test-bucket" + assert calls[1].kwargs["recording_id"] == "rec-456" + assert calls[1].kwargs["daily_room_name"] == "test-room-20251118130000" + assert calls[1].kwargs["track_keys"] == ["track1.webm"] + + +@pytest.mark.asyncio +@patch("reflector.worker.process.settings") +@patch("reflector.worker.process.create_platform_client") +@patch("reflector.worker.process.recordings_controller.get_by_ids") +@patch("reflector.worker.process.process_multitrack_recording.delay") +async def test_poll_daily_recordings_skips_existing_recordings( + mock_process_delay, + mock_get_recordings, + mock_create_client, + mock_settings, + mock_recording_response, +): + """Test that poll_daily_recordings skips recordings already in DB.""" + mock_settings.DAILYCO_STORAGE_AWS_BUCKET_NAME = "test-bucket" + + # Mock Daily.co API client + mock_daily_client = AsyncMock() + mock_daily_client.list_recordings = AsyncMock(return_value=mock_recording_response) + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + # Mock DB controller - all recordings already exist + from reflector.db.recordings import Recording + + mock_get_recordings.return_value = [ + Recording( + id="rec-123", + bucket_name="test-bucket", + object_key="", + recorded_at=datetime.now(timezone.utc), + meeting_id="meeting-1", + ), + Recording( + id="rec-456", + bucket_name="test-bucket", + object_key="", + recorded_at=datetime.now(timezone.utc), + meeting_id="meeting-1", + ), + ] + + # Execute - call the unwrapped async function + poll_fn = _get_poll_daily_recordings_fn() + await poll_fn() + + # Verify Daily.co API was called + assert mock_daily_client.list_recordings.call_count == 1 + + # Verify NO processing was queued (all recordings already exist) + assert mock_process_delay.call_count == 0 + + +@pytest.mark.asyncio +@patch("reflector.worker.process.settings") +@patch("reflector.worker.process.create_platform_client") +async def test_poll_daily_recordings_skips_when_bucket_not_configured( + mock_create_client, + mock_settings, +): + """Test that poll_daily_recordings returns early when bucket is not configured.""" + # No bucket configured + mock_settings.DAILYCO_STORAGE_AWS_BUCKET_NAME = None + + # Mock should not be called + mock_daily_client = AsyncMock() + mock_create_client.return_value.__aenter__ = AsyncMock( + return_value=mock_daily_client + ) + mock_create_client.return_value.__aexit__ = AsyncMock() + + # Execute - call the unwrapped async function + poll_fn = _get_poll_daily_recordings_fn() + await poll_fn() + + # Verify API was never called + mock_daily_client.list_recordings.assert_not_called() diff --git a/server/tests/test_utils_daily.py b/server/tests/test_utils_daily.py index 356ffc94..0b2d3929 100644 --- a/server/tests/test_utils_daily.py +++ b/server/tests/test_utils_daily.py @@ -1,6 +1,6 @@ import pytest -from reflector.utils.daily import extract_base_room_name +from reflector.utils.daily import extract_base_room_name, parse_daily_recording_filename @pytest.mark.parametrize( @@ -15,3 +15,50 @@ from reflector.utils.daily import extract_base_room_name ) def test_extract_base_room_name(daily_room_name, expected): assert extract_base_room_name(daily_room_name) == expected + + +@pytest.mark.parametrize( + "filename,expected_recording_ts,expected_participant_id,expected_track_ts", + [ + ( + "1763152299562-12f0b87c-97d4-4dd3-a65c-cee1f854a79c-cam-audio-1763152314582", + 1763152299562, + "12f0b87c-97d4-4dd3-a65c-cee1f854a79c", + 1763152314582, + ), + ( + "1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922", + 1760988935484, + "52f7f48b-fbab-431f-9a50-87b9abfc8255", + 1760988935922, + ), + ( + "1760988935484-a37c35e3-6f8e-4274-a482-e9d0f102a732-cam-audio-1760988943823", + 1760988935484, + "a37c35e3-6f8e-4274-a482-e9d0f102a732", + 1760988943823, + ), + ( + "path/to/1763151171834-b6719a43-4481-483a-a8fc-2ae18b69283c-cam-audio-1763151180561", + 1763151171834, + "b6719a43-4481-483a-a8fc-2ae18b69283c", + 1763151180561, + ), + ], +) +def test_parse_daily_recording_filename( + filename, expected_recording_ts, expected_participant_id, expected_track_ts +): + result = parse_daily_recording_filename(filename) + + assert result.recording_start_ts == expected_recording_ts + assert result.participant_id == expected_participant_id + assert result.track_start_ts == expected_track_ts + + +def test_parse_daily_recording_filename_invalid(): + with pytest.raises(ValueError, match="Invalid Daily.co recording filename"): + parse_daily_recording_filename("invalid-filename") + + with pytest.raises(ValueError, match="Invalid Daily.co recording filename"): + parse_daily_recording_filename("123-not-a-uuid-cam-audio-456") From c442a627873ca667656eeaefb63e54ab10b8d19e Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Mon, 24 Nov 2025 23:10:34 -0500 Subject: [PATCH 02/26] fix: default platform fix (#736) * default platform fix * default platform fix * default platform fix * Update server/reflector/db/rooms.py Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com> * default platform fix --------- Co-authored-by: Igor Loskutov Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com> --- ...5_make_room_platform_non_nullable_with_.py | 30 ++++++++++ server/reflector/db/meetings.py | 3 +- server/reflector/db/rooms.py | 46 ++++++++------- server/reflector/video_platforms/factory.py | 9 --- server/reflector/views/rooms.py | 17 +----- server/reflector/worker/ics_sync.py | 4 +- server/tests/test_video_platforms_factory.py | 58 ------------------- 7 files changed, 60 insertions(+), 107 deletions(-) create mode 100644 server/migrations/versions/5d6b9df9b045_make_room_platform_non_nullable_with_.py delete mode 100644 server/tests/test_video_platforms_factory.py diff --git a/server/migrations/versions/5d6b9df9b045_make_room_platform_non_nullable_with_.py b/server/migrations/versions/5d6b9df9b045_make_room_platform_non_nullable_with_.py new file mode 100644 index 00000000..3b1e4e88 --- /dev/null +++ b/server/migrations/versions/5d6b9df9b045_make_room_platform_non_nullable_with_.py @@ -0,0 +1,30 @@ +"""Make room platform non-nullable with dynamic default + +Revision ID: 5d6b9df9b045 +Revises: 2b92a1b03caa +Create Date: 2025-11-21 13:22:25.756584 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "5d6b9df9b045" +down_revision: Union[str, None] = "2b92a1b03caa" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("UPDATE room SET platform = 'whereby' WHERE platform IS NULL") + + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.alter_column("platform", existing_type=sa.String(), nullable=False) + + +def downgrade() -> None: + with op.batch_alter_table("room", schema=None) as batch_op: + batch_op.alter_column("platform", existing_type=sa.String(), nullable=True) diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py index 9c290fa5..8a80e756 100644 --- a/server/reflector/db/meetings.py +++ b/server/reflector/db/meetings.py @@ -10,7 +10,6 @@ from reflector.db.rooms import Room from reflector.schemas.platform import WHEREBY_PLATFORM, Platform from reflector.utils import generate_uuid4 from reflector.utils.string import assert_equal -from reflector.video_platforms.factory import get_platform meetings = sa.Table( "meeting", @@ -140,7 +139,7 @@ class MeetingController: recording_trigger=room.recording_trigger, calendar_event_id=calendar_event_id, calendar_metadata=calendar_metadata, - platform=get_platform(room.platform), + platform=room.platform, ) query = meetings.insert().values(**meeting.model_dump()) await get_database().execute(query) diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py index 1081ac38..fc6194e3 100644 --- a/server/reflector/db/rooms.py +++ b/server/reflector/db/rooms.py @@ -10,6 +10,7 @@ from sqlalchemy.sql import false, or_ from reflector.db import get_database, metadata from reflector.schemas.platform import Platform +from reflector.settings import settings from reflector.utils import generate_uuid4 rooms = sqlalchemy.Table( @@ -54,8 +55,7 @@ rooms = sqlalchemy.Table( sqlalchemy.Column( "platform", sqlalchemy.String, - nullable=True, - server_default=None, + nullable=False, ), sqlalchemy.Index("idx_room_is_shared", "is_shared"), sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"), @@ -84,7 +84,7 @@ class Room(BaseModel): ics_enabled: bool = False ics_last_sync: datetime | None = None ics_last_etag: str | None = None - platform: Platform | None = None + platform: Platform = Field(default_factory=lambda: settings.DEFAULT_VIDEO_PLATFORM) class RoomController: @@ -138,7 +138,7 @@ class RoomController: ics_url: str | None = None, ics_fetch_interval: int = 300, ics_enabled: bool = False, - platform: Platform | None = None, + platform: Platform = settings.DEFAULT_VIDEO_PLATFORM, ): """ Add a new room @@ -146,24 +146,26 @@ class RoomController: if webhook_url and not webhook_secret: webhook_secret = secrets.token_urlsafe(32) - room = Room( - name=name, - user_id=user_id, - zulip_auto_post=zulip_auto_post, - zulip_stream=zulip_stream, - zulip_topic=zulip_topic, - is_locked=is_locked, - room_mode=room_mode, - recording_type=recording_type, - recording_trigger=recording_trigger, - is_shared=is_shared, - webhook_url=webhook_url, - webhook_secret=webhook_secret, - ics_url=ics_url, - ics_fetch_interval=ics_fetch_interval, - ics_enabled=ics_enabled, - platform=platform, - ) + room_data = { + "name": name, + "user_id": user_id, + "zulip_auto_post": zulip_auto_post, + "zulip_stream": zulip_stream, + "zulip_topic": zulip_topic, + "is_locked": is_locked, + "room_mode": room_mode, + "recording_type": recording_type, + "recording_trigger": recording_trigger, + "is_shared": is_shared, + "webhook_url": webhook_url, + "webhook_secret": webhook_secret, + "ics_url": ics_url, + "ics_fetch_interval": ics_fetch_interval, + "ics_enabled": ics_enabled, + "platform": platform, + } + + room = Room(**room_data) query = rooms.insert().values(**room.model_dump()) try: await get_database().execute(query) diff --git a/server/reflector/video_platforms/factory.py b/server/reflector/video_platforms/factory.py index 172d45e7..7c60c379 100644 --- a/server/reflector/video_platforms/factory.py +++ b/server/reflector/video_platforms/factory.py @@ -1,5 +1,3 @@ -from typing import Optional - from reflector.settings import settings from reflector.storage import get_dailyco_storage, get_whereby_storage @@ -53,10 +51,3 @@ def get_platform_config(platform: Platform) -> VideoPlatformConfig: def create_platform_client(platform: Platform) -> VideoPlatformClient: config = get_platform_config(platform) return get_platform_client(platform, config) - - -def get_platform(room_platform: Optional[Platform] = None) -> Platform: - if room_platform: - return room_platform - - return settings.DEFAULT_VIDEO_PLATFORM diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index e786b0d9..baafaffe 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -19,10 +19,7 @@ from reflector.schemas.platform import Platform from reflector.services.ics_sync import ics_sync_service from reflector.settings import settings from reflector.utils.url import add_query_param -from reflector.video_platforms.factory import ( - create_platform_client, - get_platform, -) +from reflector.video_platforms.factory import create_platform_client from reflector.worker.webhook import test_webhook logger = logging.getLogger(__name__) @@ -190,9 +187,6 @@ async def rooms_list( ), ) - for room in paginated.items: - room.platform = get_platform(room.platform) - return paginated @@ -207,7 +201,6 @@ async def rooms_get( raise HTTPException(status_code=404, detail="Room not found") if not room.is_shared and (user_id is None or room.user_id != user_id): raise HTTPException(status_code=403, detail="Room access denied") - room.platform = get_platform(room.platform) return room @@ -229,8 +222,6 @@ async def rooms_get_by_name( room_dict["webhook_url"] = None room_dict["webhook_secret"] = None - room_dict["platform"] = get_platform(room.platform) - return RoomDetails(**room_dict) @@ -275,7 +266,6 @@ async def rooms_update( raise HTTPException(status_code=403, detail="Not authorized") values = info.dict(exclude_unset=True) await rooms_controller.update(room, values) - room.platform = get_platform(room.platform) return room @@ -323,7 +313,7 @@ async def rooms_create_meeting( if meeting is None: end_date = current_time + timedelta(hours=8) - platform = get_platform(room.platform) + platform = room.platform client = create_platform_client(platform) meeting_data = await client.create_meeting( @@ -513,9 +503,8 @@ async def rooms_list_active_meetings( room=room, current_time=current_time ) - effective_platform = get_platform(room.platform) for meeting in meetings: - meeting.platform = effective_platform + meeting.platform = room.platform if user_id != room.user_id: for meeting in meetings: diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index 6881dfa2..6e126309 100644 --- a/server/reflector/worker/ics_sync.py +++ b/server/reflector/worker/ics_sync.py @@ -10,7 +10,7 @@ from reflector.db.meetings import meetings_controller from reflector.db.rooms import Room, rooms_controller from reflector.redis_cache import RedisAsyncLock from reflector.services.ics_sync import SyncStatus, ics_sync_service -from reflector.video_platforms.factory import create_platform_client, get_platform +from reflector.video_platforms.factory import create_platform_client logger = structlog.wrap_logger(get_task_logger(__name__)) @@ -104,7 +104,7 @@ async def create_upcoming_meetings_for_event(event, create_window, room: Room): try: end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION) - client = create_platform_client(get_platform(room.platform)) + client = create_platform_client(room.platform) meeting_data = await client.create_meeting( room.name, diff --git a/server/tests/test_video_platforms_factory.py b/server/tests/test_video_platforms_factory.py deleted file mode 100644 index 6c8c02c5..00000000 --- a/server/tests/test_video_platforms_factory.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Tests for video_platforms.factory module.""" - -from unittest.mock import patch - -from reflector.video_platforms.factory import get_platform - - -class TestGetPlatformF: - """Test suite for get_platform function.""" - - @patch("reflector.video_platforms.factory.settings") - def test_with_room_platform(self, mock_settings): - """When room_platform provided, should return room_platform.""" - mock_settings.DEFAULT_VIDEO_PLATFORM = "whereby" - - # Should return the room's platform when provided - assert get_platform(room_platform="daily") == "daily" - assert get_platform(room_platform="whereby") == "whereby" - - @patch("reflector.video_platforms.factory.settings") - def test_without_room_platform_uses_default(self, mock_settings): - """When no room_platform, should return DEFAULT_VIDEO_PLATFORM.""" - mock_settings.DEFAULT_VIDEO_PLATFORM = "whereby" - - # Should return default when room_platform is None - assert get_platform(room_platform=None) == "whereby" - - @patch("reflector.video_platforms.factory.settings") - def test_with_daily_default(self, mock_settings): - """When DEFAULT_VIDEO_PLATFORM is 'daily', should return 'daily' when no room_platform.""" - mock_settings.DEFAULT_VIDEO_PLATFORM = "daily" - - # Should return default 'daily' when room_platform is None - assert get_platform(room_platform=None) == "daily" - - @patch("reflector.video_platforms.factory.settings") - def test_no_room_id_provided(self, mock_settings): - """Should work correctly even when room_id is not provided.""" - mock_settings.DEFAULT_VIDEO_PLATFORM = "whereby" - - # Should use room_platform when provided - assert get_platform(room_platform="daily") == "daily" - - # Should use default when room_platform not provided - assert get_platform(room_platform=None) == "whereby" - - @patch("reflector.video_platforms.factory.settings") - def test_room_platform_always_takes_precedence(self, mock_settings): - """room_platform should always be used when provided.""" - mock_settings.DEFAULT_VIDEO_PLATFORM = "whereby" - - # room_platform should take precedence over default - assert get_platform(room_platform="daily") == "daily" - assert get_platform(room_platform="whereby") == "whereby" - - # Different default shouldn't matter when room_platform provided - mock_settings.DEFAULT_VIDEO_PLATFORM = "daily" - assert get_platform(room_platform="whereby") == "whereby" From 86ac23868b492fa0158681d01bc6038ff28103c2 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 25 Nov 2025 11:02:33 -0600 Subject: [PATCH 03/26] chore(main): release 0.19.0 (#727) --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 083f5b2e..1c12b229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.19.0](https://github.com/Monadical-SAS/reflector/compare/v0.18.0...v0.19.0) (2025-11-25) + + +### Features + +* dailyco api module ([#725](https://github.com/Monadical-SAS/reflector/issues/725)) ([4287f8b](https://github.com/Monadical-SAS/reflector/commit/4287f8b8aeee60e51db7539f4dcbda5f6e696bd8)) +* dailyco poll ([#730](https://github.com/Monadical-SAS/reflector/issues/730)) ([8e438ca](https://github.com/Monadical-SAS/reflector/commit/8e438ca285152bd48fdc42767e706fb448d3525c)) +* multitrack cli ([#735](https://github.com/Monadical-SAS/reflector/issues/735)) ([11731c9](https://github.com/Monadical-SAS/reflector/commit/11731c9d38439b04e93b1c3afbd7090bad11a11f)) + + +### Bug Fixes + +* default platform fix ([#736](https://github.com/Monadical-SAS/reflector/issues/736)) ([c442a62](https://github.com/Monadical-SAS/reflector/commit/c442a627873ca667656eeaefb63e54ab10b8d19e)) +* parakeet vad not getting the end timestamp ([#728](https://github.com/Monadical-SAS/reflector/issues/728)) ([18ed713](https://github.com/Monadical-SAS/reflector/commit/18ed7133693653ef4ddac6c659a8c14b320d1657)) +* start raw tracks recording ([#729](https://github.com/Monadical-SAS/reflector/issues/729)) ([3e47c2c](https://github.com/Monadical-SAS/reflector/commit/3e47c2c0573504858e0d2e1798b6ed31f16b4a5d)) + ## [0.18.0](https://github.com/Monadical-SAS/reflector/compare/v0.17.0...v0.18.0) (2025-11-14) From 9bec39808fc6322612d8b87e922a6f7901fc01c1 Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Tue, 25 Nov 2025 19:13:19 +0100 Subject: [PATCH 04/26] feat: link transcript participants (#737) * Sync authentik users * Migrate user_id from uid to id * Fix auth user id * Fix ci migration test * Fix meeting token creation * Move user id migration to a script * Add user on first login * Fix migration chain * Rename uid column to authentik_uid * Fix broken ws test --- .../versions/bbafedfa510c_add_user_table.py | 38 +++ server/reflector/auth/auth_jwt.py | 18 +- server/reflector/db/__init__.py | 1 + server/reflector/db/users.py | 92 ++++++ server/reflector/views/rooms.py | 34 +- server/reflector/views/user_websocket.py | 14 +- server/scripts/migrate_user_ids.py | 292 ++++++++++++++++++ server/tests/test_user_websocket_auth.py | 15 +- www/.env.example | 3 +- www/app/[roomName]/components/DailyRoom.tsx | 55 +++- www/app/lib/authBackend.ts | 26 +- 11 files changed, 559 insertions(+), 29 deletions(-) create mode 100644 server/migrations/versions/bbafedfa510c_add_user_table.py create mode 100644 server/reflector/db/users.py create mode 100755 server/scripts/migrate_user_ids.py diff --git a/server/migrations/versions/bbafedfa510c_add_user_table.py b/server/migrations/versions/bbafedfa510c_add_user_table.py new file mode 100644 index 00000000..2078184a --- /dev/null +++ b/server/migrations/versions/bbafedfa510c_add_user_table.py @@ -0,0 +1,38 @@ +"""add user table + +Revision ID: bbafedfa510c +Revises: 5d6b9df9b045 +Create Date: 2025-11-19 21:06:30.543262 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "bbafedfa510c" +down_revision: Union[str, None] = "5d6b9df9b045" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "user", + sa.Column("id", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=False), + sa.Column("authentik_uid", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + + with op.batch_alter_table("user", schema=None) as batch_op: + batch_op.create_index("idx_user_authentik_uid", ["authentik_uid"], unique=True) + batch_op.create_index("idx_user_email", ["email"], unique=False) + + +def downgrade() -> None: + op.drop_table("user") diff --git a/server/reflector/auth/auth_jwt.py b/server/reflector/auth/auth_jwt.py index 0dcff9a0..625a371b 100644 --- a/server/reflector/auth/auth_jwt.py +++ b/server/reflector/auth/auth_jwt.py @@ -6,8 +6,10 @@ from jose import JWTError, jwt from pydantic import BaseModel from reflector.db.user_api_keys import user_api_keys_controller +from reflector.db.users import user_controller from reflector.logger import logger from reflector.settings import settings +from reflector.utils import generate_uuid4 oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) @@ -74,9 +76,21 @@ async def _authenticate_user( if jwt_token: try: payload = jwtauth.verify_token(jwt_token) - sub = payload["sub"] + authentik_uid = payload["sub"] email = payload["email"] - user_infos.append(UserInfo(sub=sub, email=email)) + + user = await user_controller.get_by_authentik_uid(authentik_uid) + if not user: + logger.info( + f"Creating new user on first login: {authentik_uid} ({email})" + ) + user = await user_controller.create_or_update( + id=generate_uuid4(), + authentik_uid=authentik_uid, + email=email, + ) + + user_infos.append(UserInfo(sub=user.id, email=email)) except JWTError as e: logger.error(f"JWT error: {e}") raise HTTPException(status_code=401, detail="Invalid authentication") diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py index 91ed12ee..deffb52a 100644 --- a/server/reflector/db/__init__.py +++ b/server/reflector/db/__init__.py @@ -31,6 +31,7 @@ import reflector.db.recordings # noqa import reflector.db.rooms # noqa import reflector.db.transcripts # noqa import reflector.db.user_api_keys # noqa +import reflector.db.users # noqa kwargs = {} if "postgres" not in settings.DATABASE_URL: diff --git a/server/reflector/db/users.py b/server/reflector/db/users.py new file mode 100644 index 00000000..ccbe11d6 --- /dev/null +++ b/server/reflector/db/users.py @@ -0,0 +1,92 @@ +"""User table for storing Authentik user information.""" + +from datetime import datetime, timezone + +import sqlalchemy +from pydantic import BaseModel, Field + +from reflector.db import get_database, metadata +from reflector.utils import generate_uuid4 +from reflector.utils.string import NonEmptyString + +users = sqlalchemy.Table( + "user", + metadata, + sqlalchemy.Column("id", sqlalchemy.String, primary_key=True), + sqlalchemy.Column("email", sqlalchemy.String, nullable=False), + sqlalchemy.Column("authentik_uid", sqlalchemy.String, nullable=False), + sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False), + sqlalchemy.Column("updated_at", sqlalchemy.DateTime(timezone=True), nullable=False), + sqlalchemy.Index("idx_user_authentik_uid", "authentik_uid", unique=True), + sqlalchemy.Index("idx_user_email", "email", unique=False), +) + + +class User(BaseModel): + id: NonEmptyString = Field(default_factory=generate_uuid4) + email: NonEmptyString + authentik_uid: NonEmptyString + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + +class UserController: + @staticmethod + async def get_by_id(user_id: NonEmptyString) -> User | None: + query = users.select().where(users.c.id == user_id) + result = await get_database().fetch_one(query) + return User(**result) if result else None + + @staticmethod + async def get_by_authentik_uid(authentik_uid: NonEmptyString) -> User | None: + query = users.select().where(users.c.authentik_uid == authentik_uid) + result = await get_database().fetch_one(query) + return User(**result) if result else None + + @staticmethod + async def get_by_email(email: NonEmptyString) -> User | None: + query = users.select().where(users.c.email == email) + result = await get_database().fetch_one(query) + return User(**result) if result else None + + @staticmethod + async def create_or_update( + id: NonEmptyString, authentik_uid: NonEmptyString, email: NonEmptyString + ) -> User: + existing = await UserController.get_by_authentik_uid(authentik_uid) + now = datetime.now(timezone.utc) + + if existing: + query = ( + users.update() + .where(users.c.authentik_uid == authentik_uid) + .values(email=email, updated_at=now) + ) + await get_database().execute(query) + return User( + id=existing.id, + authentik_uid=authentik_uid, + email=email, + created_at=existing.created_at, + updated_at=now, + ) + else: + user = User( + id=id, + authentik_uid=authentik_uid, + email=email, + created_at=now, + updated_at=now, + ) + query = users.insert().values(**user.model_dump()) + await get_database().execute(query) + return user + + @staticmethod + async def list_all() -> list[User]: + query = users.select().order_by(users.c.created_at.desc()) + results = await get_database().fetch_all(query) + return [User(**r) for r in results] + + +user_controller = UserController() diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index baafaffe..6d1ba358 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -337,19 +337,7 @@ async def rooms_create_meeting( status_code=503, detail="Meeting creation in progress, please try again" ) - if meeting.platform == "daily" and room.recording_trigger != "none": - client = create_platform_client(meeting.platform) - token = await client.create_meeting_token( - meeting.room_name, - enable_recording=True, - user_id=user_id, - ) - meeting = meeting.model_copy() - meeting.room_url = add_query_param(meeting.room_url, "t", token) - if meeting.host_room_url: - meeting.host_room_url = add_query_param(meeting.host_room_url, "t", token) - - if user_id != room.user_id: + if user_id != room.user_id and meeting.platform == "whereby": meeting.host_room_url = "" return meeting @@ -508,7 +496,8 @@ async def rooms_list_active_meetings( if user_id != room.user_id: for meeting in meetings: - meeting.host_room_url = "" + if meeting.platform == "whereby": + meeting.host_room_url = "" return meetings @@ -530,7 +519,7 @@ async def rooms_get_meeting( if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") - if user_id != room.user_id and not room.is_shared: + if user_id != room.user_id and not room.is_shared and meeting.platform == "whereby": meeting.host_room_url = "" return meeting @@ -560,7 +549,20 @@ async def rooms_join_meeting( if meeting.end_date <= current_time: raise HTTPException(status_code=400, detail="Meeting has ended") - if user_id != room.user_id: + if meeting.platform == "daily": + client = create_platform_client(meeting.platform) + enable_recording = room.recording_trigger != "none" + token = await client.create_meeting_token( + meeting.room_name, + enable_recording=enable_recording, + user_id=user_id, + ) + meeting = meeting.model_copy() + meeting.room_url = add_query_param(meeting.room_url, "t", token) + if meeting.host_room_url: + meeting.host_room_url = add_query_param(meeting.host_room_url, "t", token) + + if user_id != room.user_id and meeting.platform == "whereby": meeting.host_room_url = "" return meeting diff --git a/server/reflector/views/user_websocket.py b/server/reflector/views/user_websocket.py index 26d3c8ac..b556f4c4 100644 --- a/server/reflector/views/user_websocket.py +++ b/server/reflector/views/user_websocket.py @@ -3,6 +3,7 @@ from typing import Optional from fastapi import APIRouter, WebSocket from reflector.auth.auth_jwt import JWTAuth # type: ignore +from reflector.db.users import user_controller from reflector.ws_manager import get_ws_manager router = APIRouter() @@ -29,7 +30,18 @@ async def user_events_websocket(websocket: WebSocket): try: payload = JWTAuth().verify_token(token) - user_id = payload.get("sub") + authentik_uid = payload.get("sub") + + if authentik_uid: + user = await user_controller.get_by_authentik_uid(authentik_uid) + if user: + user_id = user.id + else: + await websocket.close(code=UNAUTHORISED) + return + else: + await websocket.close(code=UNAUTHORISED) + return except Exception: await websocket.close(code=UNAUTHORISED) return diff --git a/server/scripts/migrate_user_ids.py b/server/scripts/migrate_user_ids.py new file mode 100755 index 00000000..4fcffe71 --- /dev/null +++ b/server/scripts/migrate_user_ids.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +""" +Manual Migration Script: Migrate user_id from Authentik UID to internal user.id + +This script should be run manually AFTER applying the database schema migrations. + +Usage: + AUTHENTIK_API_URL=https://your-authentik-url \ + AUTHENTIK_API_TOKEN=your-token \ + DATABASE_URL=postgresql://... \ + python scripts/migrate_user_ids.py + +What this script does: +1. Collects all unique Authentik UIDs currently used in the database +2. Fetches only those users from Authentik API to populate the users table +3. Updates user_id in: user_api_key, transcript, room, meeting_consent +4. Uses user.authentik_uid to lookup the corresponding user.id + +The script is idempotent: +- User inserts use ON CONFLICT DO NOTHING (safe if users already exist) +- Update queries only match authentik_uid->uuid pairs (no-op if already migrated) +- Safe to run multiple times without side effects + +Prerequisites: +- AUTHENTIK_API_URL environment variable must be set +- AUTHENTIK_API_TOKEN environment variable must be set +- DATABASE_URL environment variable must be set +- Authentik API must be accessible +""" + +import asyncio +import os +import sys +from datetime import datetime, timezone +from typing import Any + +import httpx +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncConnection, create_async_engine + +TABLES_WITH_USER_ID = ["user_api_key", "transcript", "room", "meeting_consent"] +NULLABLE_USER_ID_TABLES = {"transcript", "meeting_consent"} +AUTHENTIK_PAGE_SIZE = 100 +HTTP_TIMEOUT = 30.0 + + +class AuthentikClient: + def __init__(self, api_url: str, api_token: str): + self.api_url = api_url + self.api_token = api_token + + def _get_headers(self) -> dict[str, str]: + return { + "Authorization": f"Bearer {self.api_token}", + "Accept": "application/json", + } + + async def fetch_all_users(self) -> list[dict[str, Any]]: + all_users = [] + page = 1 + + try: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + while True: + url = f"{self.api_url}/api/v3/core/users/" + params = { + "page": page, + "page_size": AUTHENTIK_PAGE_SIZE, + "include_groups": "false", + } + + print(f" Fetching users from Authentik (page {page})...") + response = await client.get( + url, headers=self._get_headers(), params=params + ) + response.raise_for_status() + data = response.json() + + results = data.get("results", []) + if not results: + break + + all_users.extend(results) + print(f" Fetched {len(results)} users from page {page}") + + if not data.get("next"): + break + + page += 1 + + print(f" Total: {len(all_users)} users fetched from Authentik") + return all_users + + except httpx.HTTPError as e: + raise Exception(f"Failed to fetch users from Authentik: {e}") from e + + +async def collect_used_authentik_uids(connection: AsyncConnection) -> set[str]: + print("\nStep 1: Collecting Authentik UIDs from database tables...") + used_authentik_uids = set() + + for table in TABLES_WITH_USER_ID: + result = await connection.execute( + text(f'SELECT DISTINCT user_id FROM "{table}" WHERE user_id IS NOT NULL') + ) + authentik_uids = [row[0] for row in result.fetchall()] + used_authentik_uids.update(authentik_uids) + print(f" Found {len(authentik_uids)} unique Authentik UIDs in {table}") + + print(f" Total unique user IDs found: {len(used_authentik_uids)}") + + if used_authentik_uids: + sample_id = next(iter(used_authentik_uids)) + if len(sample_id) == 36 and sample_id.count("-") == 4: + print( + f"\n✅ User IDs are already in UUID format (e.g., {sample_id[:20]}...)" + ) + print("Migration has already been completed!") + return set() + + return used_authentik_uids + + +def filter_users_by_authentik_uid( + authentik_users: list[dict[str, Any]], used_authentik_uids: set[str] +) -> tuple[list[dict[str, Any]], set[str]]: + used_authentik_users = [ + user for user in authentik_users if user.get("uid") in used_authentik_uids + ] + + missing_ids = used_authentik_uids - {u.get("uid") for u in used_authentik_users} + + print( + f" Found {len(used_authentik_users)} matching users in Authentik " + f"(out of {len(authentik_users)} total)" + ) + + if missing_ids: + print( + f" ⚠ Warning: {len(missing_ids)} Authentik UIDs in database not found in Authentik:" + ) + for user_id in sorted(missing_ids): + print(f" - {user_id}") + + return used_authentik_users, missing_ids + + +async def sync_users_to_database( + connection: AsyncConnection, authentik_users: list[dict[str, Any]] +) -> tuple[int, int]: + created = 0 + skipped = 0 + now = datetime.now(timezone.utc) + + for authentik_user in authentik_users: + user_id = authentik_user["uuid"] + authentik_uid = authentik_user["uid"] + email = authentik_user.get("email") + + if not email: + print(f" ⚠ Skipping user {authentik_uid} (no email)") + skipped += 1 + continue + + result = await connection.execute( + text(""" + INSERT INTO "user" (id, email, authentik_uid, created_at, updated_at) + VALUES (:id, :email, :authentik_uid, :created_at, :updated_at) + ON CONFLICT (id) DO NOTHING + """), + { + "id": user_id, + "email": email, + "authentik_uid": authentik_uid, + "created_at": now, + "updated_at": now, + }, + ) + if result.rowcount > 0: + created += 1 + + return created, skipped + + +async def migrate_all_user_ids(connection: AsyncConnection) -> int: + print("\nStep 3: Migrating user_id columns from Authentik UID to internal UUID...") + print("(If no rows are updated, migration may have already been completed)") + + total_updated = 0 + + for table in TABLES_WITH_USER_ID: + null_check = ( + f"AND {table}.user_id IS NOT NULL" + if table in NULLABLE_USER_ID_TABLES + else "" + ) + + query = f""" + UPDATE {table} + SET user_id = u.id + FROM "user" u + WHERE {table}.user_id = u.authentik_uid + {null_check} + """ + + print(f" Updating {table}.user_id...") + result = await connection.execute(text(query)) + rows = result.rowcount + print(f" ✓ Updated {rows} rows") + total_updated += rows + + return total_updated + + +async def run_migration( + database_url: str, authentik_api_url: str, authentik_api_token: str +) -> None: + engine = create_async_engine(database_url) + + try: + async with engine.begin() as connection: + used_authentik_uids = await collect_used_authentik_uids(connection) + if not used_authentik_uids: + print("\n⚠️ No user IDs found in database. Nothing to migrate.") + print("Migration complete (no-op)!") + return + + print("\nStep 2: Fetching user data from Authentik and syncing users...") + print("(This script is idempotent - safe to run multiple times)") + print(f"Authentik API URL: {authentik_api_url}") + + client = AuthentikClient(authentik_api_url, authentik_api_token) + authentik_users = await client.fetch_all_users() + + if not authentik_users: + print("\nERROR: No users returned from Authentik API.") + print( + "Please verify your Authentik configuration and ensure users exist." + ) + sys.exit(1) + + used_authentik_users, _ = filter_users_by_authentik_uid( + authentik_users, used_authentik_uids + ) + created, skipped = await sync_users_to_database( + connection, used_authentik_users + ) + + if created > 0: + print(f"✓ Created {created} users from Authentik") + else: + print("✓ No new users created (users may already exist)") + + if skipped > 0: + print(f" ⚠ Skipped {skipped} users without email") + + result = await connection.execute(text('SELECT COUNT(*) FROM "user"')) + user_count = result.scalar() + print(f"✓ Users table now has {user_count} users") + + total_updated = await migrate_all_user_ids(connection) + + if total_updated > 0: + print(f"\n✅ Migration complete! Updated {total_updated} total rows.") + else: + print( + "\n✅ Migration complete! (No rows updated - migration may have already been completed)" + ) + + except Exception as e: + print(f"\n❌ ERROR: Migration failed: {e}") + sys.exit(1) + finally: + await engine.dispose() + + +async def main() -> None: + database_url = os.getenv("DATABASE_URL") + authentik_api_url = os.getenv("AUTHENTIK_API_URL") + authentik_api_token = os.getenv("AUTHENTIK_API_TOKEN") + + if not database_url or not authentik_api_url or not authentik_api_token: + print( + "ERROR: DATABASE_URL, AUTHENTIK_API_URL, and AUTHENTIK_API_TOKEN must be set" + ) + sys.exit(1) + + await run_migration(database_url, authentik_api_url, authentik_api_token) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/server/tests/test_user_websocket_auth.py b/server/tests/test_user_websocket_auth.py index be1a2816..5a40440f 100644 --- a/server/tests/test_user_websocket_auth.py +++ b/server/tests/test_user_websocket_auth.py @@ -120,7 +120,15 @@ async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user host, port = appserver_ws_user base_ws = f"http://{host}:{port}/v1/events" - token = _make_dummy_jwt("user-abc") + # Create a test user in the database + from reflector.db.users import user_controller + + test_uid = "user-abc" + user = await user_controller.create_or_update( + id="test-user-id-abc", authentik_uid=test_uid, email="user-abc@example.com" + ) + + token = _make_dummy_jwt(test_uid) subprotocols = ["bearer", token] # Connect and then trigger an event via HTTP create @@ -132,12 +140,13 @@ async def test_user_ws_accepts_valid_token_and_receives_events(appserver_ws_user from reflector.auth import current_user, current_user_optional # Override auth dependencies so HTTP request is performed as the same user + # Use the internal user.id (not the Authentik UID) app.dependency_overrides[current_user] = lambda: { - "sub": "user-abc", + "sub": user.id, "email": "user-abc@example.com", } app.dependency_overrides[current_user_optional] = lambda: { - "sub": "user-abc", + "sub": user.id, "email": "user-abc@example.com", } diff --git a/www/.env.example b/www/.env.example index da46b513..1d0b1d20 100644 --- a/www/.env.example +++ b/www/.env.example @@ -22,9 +22,10 @@ AUTHENTIK_CLIENT_SECRET=your-client-secret-here # API URLs API_URL=http://127.0.0.1:1250 +SERVER_API_URL=http://server:1250 WEBSOCKET_URL=ws://127.0.0.1:1250 AUTH_CALLBACK_URL=http://localhost:3000/auth-callback # Sentry # SENTRY_DSN=https://your-dsn@sentry.io/project-id -# SENTRY_IGNORE_API_RESOLUTION_ERROR=1 \ No newline at end of file +# SENTRY_IGNORE_API_RESOLUTION_ERROR=1 diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index cfefbf6a..2faedb90 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -1,8 +1,8 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; -import { Box } from "@chakra-ui/react"; -import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Box, Spinner, Center, Text } from "@chakra-ui/react"; +import { useRouter, useParams } from "next/navigation"; import DailyIframe, { DailyCall } from "@daily-co/daily-js"; import type { components } from "../../reflector-api"; import { useAuth } from "../../lib/AuthProvider"; @@ -10,6 +10,7 @@ import { ConsentDialogButton, recordingTypeRequiresConsent, } from "../../lib/consent"; +import { useRoomJoinMeeting } from "../../lib/apiHooks"; type Meeting = components["schemas"]["Meeting"]; @@ -19,13 +20,41 @@ interface DailyRoomProps { export default function DailyRoom({ meeting }: DailyRoomProps) { const router = useRouter(); + const params = useParams(); const auth = useAuth(); const status = auth.status; const containerRef = useRef(null); + const joinMutation = useRoomJoinMeeting(); + const [joinedMeeting, setJoinedMeeting] = useState(null); - const roomUrl = meeting?.host_room_url || meeting?.room_url; + const roomName = params?.roomName as string; - const isLoading = status === "loading"; + // Always call /join to get a fresh token with user_id + useEffect(() => { + if (status === "loading" || !meeting?.id || !roomName) return; + + const join = async () => { + try { + const result = await joinMutation.mutateAsync({ + params: { + path: { + room_name: roomName, + meeting_id: meeting.id, + }, + }, + }); + setJoinedMeeting(result); + } catch (error) { + console.error("Failed to join meeting:", error); + } + }; + + join(); + }, [meeting?.id, roomName, status]); + + const roomUrl = joinedMeeting?.host_room_url || joinedMeeting?.room_url; + const isLoading = + status === "loading" || joinMutation.isPending || !joinedMeeting; const handleLeave = useCallback(() => { router.push("/browse"); @@ -87,6 +116,22 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { }; }, [roomUrl, isLoading, handleLeave]); + if (isLoading) { + return ( +
+ +
+ ); + } + + if (joinMutation.isError) { + return ( +
+ Failed to join meeting. Please try again. +
+ ); + } + if (!roomUrl) { return null; } diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts index a44f1d36..7a8fa433 100644 --- a/www/app/lib/authBackend.ts +++ b/www/app/lib/authBackend.ts @@ -22,6 +22,27 @@ import { sequenceThrows } from "./errorUtils"; import { featureEnabled } from "./features"; import { getNextEnvVar } from "./nextBuild"; +async function getUserId(accessToken: string): Promise { + try { + const apiUrl = getNextEnvVar("SERVER_API_URL"); + const response = await fetch(`${apiUrl}/v1/me`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + return null; + } + + const userInfo = await response.json(); + return userInfo.sub || null; + } catch (error) { + console.error("Error fetching user ID from backend:", error); + return null; + } +} + const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE; const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID"); const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET"); @@ -122,13 +143,16 @@ export const authOptions = (): AuthOptions => }, async session({ session, token }) { const extendedToken = token as JWTWithAccessToken; + + const userId = await getUserId(extendedToken.accessToken); + return { ...session, accessToken: extendedToken.accessToken, accessTokenExpires: extendedToken.accessTokenExpires, error: extendedToken.error, user: { - id: assertExists(extendedToken.sub), + id: assertExistsAndNonEmptyString(userId, "User ID required"), name: extendedToken.name, email: extendedToken.email, }, From 86d5e26224bb55a0f1cc785aeda52065bb92ee6f Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Tue, 25 Nov 2025 16:28:43 -0500 Subject: [PATCH 05/26] feat: transcript restart script (#742) * transcript restart script * fix tests? * remove useless comment --------- Co-authored-by: Igor Loskutov --- server/reflector/dailyco_api/webhook_utils.py | 1 - .../reflector/services/transcript_process.py | 169 ++++++++++++++++++ server/reflector/tools/process_transcript.py | 127 +++++++++++++ server/reflector/utils/match.py | 10 ++ server/reflector/views/transcripts_process.py | 89 +++------ server/tests/test_transcripts_process.py | 8 +- 6 files changed, 337 insertions(+), 67 deletions(-) create mode 100644 server/reflector/services/transcript_process.py create mode 100644 server/reflector/tools/process_transcript.py create mode 100644 server/reflector/utils/match.py diff --git a/server/reflector/dailyco_api/webhook_utils.py b/server/reflector/dailyco_api/webhook_utils.py index b10d4fa2..27d5fb4e 100644 --- a/server/reflector/dailyco_api/webhook_utils.py +++ b/server/reflector/dailyco_api/webhook_utils.py @@ -195,7 +195,6 @@ def parse_recording_error(event: DailyWebhookEvent) -> RecordingErrorPayload: return RecordingErrorPayload(**event.payload) -# Webhook event type to parser mapping WEBHOOK_PARSERS = { "participant.joined": parse_participant_joined, "participant.left": parse_participant_left, diff --git a/server/reflector/services/transcript_process.py b/server/reflector/services/transcript_process.py new file mode 100644 index 00000000..bc48a4eb --- /dev/null +++ b/server/reflector/services/transcript_process.py @@ -0,0 +1,169 @@ +""" +Transcript processing service - shared logic for HTTP endpoints and Celery tasks. + +This module provides result-based error handling that works in both contexts: +- HTTP endpoint: converts errors to HTTPException +- Celery task: converts errors to Exception +""" + +from dataclasses import dataclass +from typing import Literal, Union + +import celery +from celery.result import AsyncResult + +from reflector.db.recordings import recordings_controller +from reflector.db.transcripts import Transcript +from reflector.pipelines.main_file_pipeline import task_pipeline_file_process +from reflector.pipelines.main_multitrack_pipeline import ( + task_pipeline_multitrack_process, +) +from reflector.utils.match import absurd +from reflector.utils.string import NonEmptyString + + +@dataclass +class ProcessError: + detail: NonEmptyString + + +@dataclass +class FileProcessingConfig: + transcript_id: NonEmptyString + mode: Literal["file"] = "file" + + +@dataclass +class MultitrackProcessingConfig: + transcript_id: NonEmptyString + bucket_name: NonEmptyString + track_keys: list[str] + mode: Literal["multitrack"] = "multitrack" + + +ProcessingConfig = Union[FileProcessingConfig, MultitrackProcessingConfig] +PrepareResult = Union[ProcessingConfig, ProcessError] + + +@dataclass +class ValidationOk: + # transcript currently doesnt always have recording_id + recording_id: NonEmptyString | None + transcript_id: NonEmptyString + + +@dataclass +class ValidationLocked: + detail: NonEmptyString + + +@dataclass +class ValidationNotReady: + detail: NonEmptyString + + +@dataclass +class ValidationAlreadyScheduled: + detail: NonEmptyString + + +ValidationError = Union[ + ValidationNotReady, ValidationLocked, ValidationAlreadyScheduled +] +ValidationResult = Union[ValidationOk, ValidationError] + + +@dataclass +class DispatchOk: + status: Literal["ok"] = "ok" + + +@dataclass +class DispatchAlreadyRunning: + status: Literal["already_running"] = "already_running" + + +DispatchResult = Union[ + DispatchOk, DispatchAlreadyRunning, ProcessError, ValidationError +] + + +async def validate_transcript_for_processing( + transcript: Transcript, +) -> ValidationResult: + if transcript.locked: + return ValidationLocked(detail="Recording is locked") + + if transcript.status == "idle": + return ValidationNotReady(detail="Recording is not ready for processing") + + if task_is_scheduled_or_active( + "reflector.pipelines.main_file_pipeline.task_pipeline_file_process", + transcript_id=transcript.id, + ) or task_is_scheduled_or_active( + "reflector.pipelines.main_multitrack_pipeline.task_pipeline_multitrack_process", + transcript_id=transcript.id, + ): + return ValidationAlreadyScheduled(detail="already running") + + return ValidationOk( + recording_id=transcript.recording_id, transcript_id=transcript.id + ) + + +async def prepare_transcript_processing(validation: ValidationOk) -> PrepareResult: + """ + Determine processing mode from transcript/recording data. + """ + bucket_name: str | None = None + track_keys: list[str] | None = None + + if validation.recording_id: + recording = await recordings_controller.get_by_id(validation.recording_id) + if recording: + bucket_name = recording.bucket_name + track_keys = recording.track_keys + + if track_keys is not None and len(track_keys) == 0: + return ProcessError( + detail="No track keys found, must be either > 0 or None", + ) + if track_keys is not None and not bucket_name: + return ProcessError( + detail="Bucket name must be specified", + ) + + if track_keys: + return MultitrackProcessingConfig( + bucket_name=bucket_name, # type: ignore (validated above) + track_keys=track_keys, + transcript_id=validation.transcript_id, + ) + + return FileProcessingConfig( + transcript_id=validation.transcript_id, + ) + + +def dispatch_transcript_processing(config: ProcessingConfig) -> AsyncResult: + if isinstance(config, MultitrackProcessingConfig): + return task_pipeline_multitrack_process.delay( + transcript_id=config.transcript_id, + bucket_name=config.bucket_name, + track_keys=config.track_keys, + ) + elif isinstance(config, FileProcessingConfig): + return task_pipeline_file_process.delay(transcript_id=config.transcript_id) + else: + absurd(config) + + +def task_is_scheduled_or_active(task_name: str, **kwargs): + inspect = celery.current_app.control.inspect() + + for worker, tasks in (inspect.scheduled() | inspect.active()).items(): + for task in tasks: + if task["name"] == task_name and task["kwargs"] == kwargs: + return True + + return False diff --git a/server/reflector/tools/process_transcript.py b/server/reflector/tools/process_transcript.py new file mode 100644 index 00000000..ce9efd71 --- /dev/null +++ b/server/reflector/tools/process_transcript.py @@ -0,0 +1,127 @@ +""" +Process transcript by ID - auto-detects multitrack vs file pipeline. + +Usage: + uv run -m reflector.tools.process_transcript + + # Or via docker: + docker compose exec server uv run -m reflector.tools.process_transcript +""" + +import argparse +import asyncio +import sys +import time +from typing import Callable + +from celery.result import AsyncResult + +from reflector.db.transcripts import Transcript, transcripts_controller +from reflector.services.transcript_process import ( + FileProcessingConfig, + MultitrackProcessingConfig, + PrepareResult, + ProcessError, + ValidationError, + ValidationResult, + dispatch_transcript_processing, + prepare_transcript_processing, + validate_transcript_for_processing, +) + + +async def process_transcript_inner( + transcript: Transcript, + on_validation: Callable[[ValidationResult], None], + on_preprocess: Callable[[PrepareResult], None], +) -> AsyncResult: + validation = await validate_transcript_for_processing(transcript) + on_validation(validation) + config = await prepare_transcript_processing(validation) + on_preprocess(config) + return dispatch_transcript_processing(config) + + +async def process_transcript(transcript_id: str, sync: bool = False) -> None: + """ + Process a transcript by ID, auto-detecting multitrack vs file pipeline. + + Args: + transcript_id: The transcript UUID + sync: If True, wait for task completion. If False, dispatch and exit. + """ + from reflector.db import get_database + + database = get_database() + await database.connect() + + try: + transcript = await transcripts_controller.get_by_id(transcript_id) + if not transcript: + print(f"Error: Transcript {transcript_id} not found", file=sys.stderr) + sys.exit(1) + + print(f"Found transcript: {transcript.title or transcript_id}", file=sys.stderr) + print(f" Status: {transcript.status}", file=sys.stderr) + print(f" Recording ID: {transcript.recording_id or 'None'}", file=sys.stderr) + + def on_validation(validation: ValidationResult) -> None: + if isinstance(validation, ValidationError): + print(f"Error: {validation.detail}", file=sys.stderr) + sys.exit(1) + + def on_preprocess(config: PrepareResult) -> None: + if isinstance(config, ProcessError): + print(f"Error: {config.detail}", file=sys.stderr) + sys.exit(1) + elif isinstance(config, MultitrackProcessingConfig): + print(f"Dispatching multitrack pipeline", file=sys.stderr) + print(f" Bucket: {config.bucket_name}", file=sys.stderr) + print(f" Tracks: {len(config.track_keys)}", file=sys.stderr) + elif isinstance(config, FileProcessingConfig): + print(f"Dispatching file pipeline", file=sys.stderr) + + result = await process_transcript_inner( + transcript, on_validation=on_validation, on_preprocess=on_preprocess + ) + + if sync: + print("Waiting for task completion...", file=sys.stderr) + while not result.ready(): + print(f" Status: {result.state}", file=sys.stderr) + time.sleep(5) + + if result.successful(): + print("Task completed successfully", file=sys.stderr) + else: + print(f"Task failed: {result.result}", file=sys.stderr) + sys.exit(1) + else: + print( + "Task dispatched (use --sync to wait for completion)", file=sys.stderr + ) + + finally: + await database.disconnect() + + +def main(): + parser = argparse.ArgumentParser( + description="Process transcript by ID - auto-detects multitrack vs file pipeline" + ) + parser.add_argument( + "transcript_id", + help="Transcript UUID to process", + ) + parser.add_argument( + "--sync", + action="store_true", + help="Wait for task completion instead of just dispatching", + ) + + args = parser.parse_args() + asyncio.run(process_transcript(args.transcript_id, sync=args.sync)) + + +if __name__ == "__main__": + main() diff --git a/server/reflector/utils/match.py b/server/reflector/utils/match.py new file mode 100644 index 00000000..e0f6bc53 --- /dev/null +++ b/server/reflector/utils/match.py @@ -0,0 +1,10 @@ +from typing import NoReturn + + +def assert_exhaustiveness(x: NoReturn) -> NoReturn: + """Provide an assertion at type-check time that this function is never called.""" + raise AssertionError(f"Invalid value: {x!r}") + + +def absurd(x: NoReturn) -> NoReturn: + return assert_exhaustiveness(x) diff --git a/server/reflector/views/transcripts_process.py b/server/reflector/views/transcripts_process.py index cee1e10d..88f11e71 100644 --- a/server/reflector/views/transcripts_process.py +++ b/server/reflector/views/transcripts_process.py @@ -1,16 +1,21 @@ from typing import Annotated, Optional -import celery from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel import reflector.auth as auth -from reflector.db.recordings import recordings_controller from reflector.db.transcripts import transcripts_controller -from reflector.pipelines.main_file_pipeline import task_pipeline_file_process -from reflector.pipelines.main_multitrack_pipeline import ( - task_pipeline_multitrack_process, +from reflector.services.transcript_process import ( + ProcessError, + ValidationAlreadyScheduled, + ValidationError, + ValidationLocked, + ValidationOk, + dispatch_transcript_processing, + prepare_transcript_processing, + validate_transcript_for_processing, ) +from reflector.utils.match import absurd router = APIRouter() @@ -23,68 +28,28 @@ class ProcessStatus(BaseModel): async def transcript_process( transcript_id: str, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], -): +) -> ProcessStatus: user_id = user["sub"] if user else None transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) - if transcript.locked: - raise HTTPException(status_code=400, detail="Transcript is locked") - - if transcript.status == "idle": - raise HTTPException( - status_code=400, detail="Recording is not ready for processing" - ) - - # avoid duplicate scheduling for either pipeline - if task_is_scheduled_or_active( - "reflector.pipelines.main_file_pipeline.task_pipeline_file_process", - transcript_id=transcript_id, - ) or task_is_scheduled_or_active( - "reflector.pipelines.main_multitrack_pipeline.task_pipeline_multitrack_process", - transcript_id=transcript_id, - ): - return ProcessStatus(status="already running") - - # Determine processing mode strictly from DB to avoid S3 scans - bucket_name = None - track_keys: list[str] = [] - - if transcript.recording_id: - recording = await recordings_controller.get_by_id(transcript.recording_id) - if recording: - bucket_name = recording.bucket_name - track_keys = recording.track_keys - if track_keys is not None and len(track_keys) == 0: - raise HTTPException( - status_code=500, - detail="No track keys found, must be either > 0 or None", - ) - if track_keys is not None and not bucket_name: - raise HTTPException( - status_code=500, detail="Bucket name must be specified" - ) - - if track_keys: - task_pipeline_multitrack_process.delay( - transcript_id=transcript_id, - bucket_name=bucket_name, - track_keys=track_keys, - ) + validation = await validate_transcript_for_processing(transcript) + if isinstance(validation, ValidationLocked): + raise HTTPException(status_code=400, detail=validation.detail) + elif isinstance(validation, ValidationError): + raise HTTPException(status_code=400, detail=validation.detail) + elif isinstance(validation, ValidationAlreadyScheduled): + return ProcessStatus(status=validation.detail) + elif isinstance(validation, ValidationOk): + pass else: - # Default single-file pipeline - task_pipeline_file_process.delay(transcript_id=transcript_id) + absurd(validation) - return ProcessStatus(status="ok") + config = await prepare_transcript_processing(validation) - -def task_is_scheduled_or_active(task_name: str, **kwargs): - inspect = celery.current_app.control.inspect() - - for worker, tasks in (inspect.scheduled() | inspect.active()).items(): - for task in tasks: - if task["name"] == task_name and task["kwargs"] == kwargs: - return True - - return False + if isinstance(config, ProcessError): + raise HTTPException(status_code=500, detail=config.detail) + else: + dispatch_transcript_processing(config) + return ProcessStatus(status="ok") diff --git a/server/tests/test_transcripts_process.py b/server/tests/test_transcripts_process.py index 3a0614c1..e3d749df 100644 --- a/server/tests/test_transcripts_process.py +++ b/server/tests/test_transcripts_process.py @@ -139,10 +139,10 @@ async def test_whereby_recording_uses_file_pipeline(client): with ( patch( - "reflector.views.transcripts_process.task_pipeline_file_process" + "reflector.services.transcript_process.task_pipeline_file_process" ) as mock_file_pipeline, patch( - "reflector.views.transcripts_process.task_pipeline_multitrack_process" + "reflector.services.transcript_process.task_pipeline_multitrack_process" ) as mock_multitrack_pipeline, ): response = await client.post(f"/transcripts/{transcript.id}/process") @@ -194,10 +194,10 @@ async def test_dailyco_recording_uses_multitrack_pipeline(client): with ( patch( - "reflector.views.transcripts_process.task_pipeline_file_process" + "reflector.services.transcript_process.task_pipeline_file_process" ) as mock_file_pipeline, patch( - "reflector.views.transcripts_process.task_pipeline_multitrack_process" + "reflector.services.transcript_process.task_pipeline_multitrack_process" ) as mock_multitrack_pipeline, ): response = await client.post(f"/transcripts/{transcript.id}/process") From 201671368a2636cc671b89415dc2386a6374708c Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 25 Nov 2025 15:32:49 -0600 Subject: [PATCH 06/26] chore(main): release 0.20.0 (#740) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c12b229..89ba42f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.20.0](https://github.com/Monadical-SAS/reflector/compare/v0.19.0...v0.20.0) (2025-11-25) + + +### Features + +* link transcript participants ([#737](https://github.com/Monadical-SAS/reflector/issues/737)) ([9bec398](https://github.com/Monadical-SAS/reflector/commit/9bec39808fc6322612d8b87e922a6f7901fc01c1)) +* transcript restart script ([#742](https://github.com/Monadical-SAS/reflector/issues/742)) ([86d5e26](https://github.com/Monadical-SAS/reflector/commit/86d5e26224bb55a0f1cc785aeda52065bb92ee6f)) + ## [0.19.0](https://github.com/Monadical-SAS/reflector/compare/v0.18.0...v0.19.0) (2025-11-25) From 689c8075cc6a3595679ccc2b7c43be3dcb264c01 Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Tue, 25 Nov 2025 17:05:46 -0500 Subject: [PATCH 07/26] transcription reprocess doc (#743) Co-authored-by: Igor Loskutov --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index d6bdb86e..78597abf 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,12 @@ You can manually process an audio file by calling the process tool: uv run python -m reflector.tools.process path/to/audio.wav ``` +## Reprocessing any transcription + +```bash +uv run -m reflector.tools.process_transcript 81ec38d1-9dd7-43d2-b3f8-51f4d34a07cd --sync +``` + ## Build-time env variables Next.js projects are more used to NEXT_PUBLIC_ prefixed buildtime vars. We don't have those for the reason we need to serve a ccustomizable prebuild docker container. From 0b2c82227de5477f6ad0a66571e8bdb622922952 Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Tue, 25 Nov 2025 22:41:54 -0500 Subject: [PATCH 08/26] is_owner pass for dailyco (#745) Co-authored-by: Igor Loskutov --- server/reflector/video_platforms/daily.py | 9 +++++---- server/reflector/views/rooms.py | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py index 2b4d2461..f7782ca9 100644 --- a/server/reflector/video_platforms/daily.py +++ b/server/reflector/video_platforms/daily.py @@ -173,15 +173,16 @@ class DailyClient(VideoPlatformClient): self, room_name: DailyRoomName, enable_recording: bool, - user_id: str | None = None, - ) -> str: + user_id: NonEmptyString | None = None, + is_owner: bool = False, + ) -> NonEmptyString: properties = MeetingTokenProperties( room_name=room_name, user_id=user_id, start_cloud_recording=enable_recording, - enable_recording_ui=not enable_recording, + enable_recording_ui=False, + is_owner=is_owner, ) - request = CreateMeetingTokenRequest(properties=properties) result = await self._api_client.create_meeting_token(request) return result.token diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index 6d1ba358..da5db1e8 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -248,7 +248,7 @@ async def rooms_create( ics_url=room.ics_url, ics_fetch_interval=room.ics_fetch_interval, ics_enabled=room.ics_enabled, - platform=room.platform, + platform=room.platform or settings.DEFAULT_VIDEO_PLATFORM, ) @@ -556,6 +556,7 @@ async def rooms_join_meeting( meeting.room_name, enable_recording=enable_recording, user_id=user_id, + is_owner=user_id == room.user_id, ) meeting = meeting.model_copy() meeting.room_url = add_query_param(meeting.room_url, "t", token) From 3aef92620363ae23cefa3ce6da32129b2ee291ac Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Tue, 25 Nov 2025 22:42:09 -0500 Subject: [PATCH 09/26] room creatio hotfix (#744) Co-authored-by: Igor Loskutov From f6ca07505f34483b02270a2ef3bd809e9d2e1045 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 26 Nov 2025 11:51:14 -0600 Subject: [PATCH 10/26] feat: add transcript format parameter to GET endpoint (#709) * feat: add transcript format parameter to GET endpoint Add transcript_format query parameter to /v1/transcripts/{id} endpoint with support for multiple output formats using discriminated unions. Formats supported: - text: Plain speaker dialogue (default) - text-timestamped: Dialogue with [MM:SS] timestamps - webvtt-named: WebVTT subtitles with participant names - json: Structured segments with full metadata Response models use Pydantic discriminated unions with transcript_format as discriminator field. POST/PATCH endpoints return GetTranscriptWithParticipants for minimal responses. GET endpoint returns format-specific models. * Copy transcript format * Regenerate types * Fix transcript formats * Don't throw inside try * Remove any type * Toast share copy errors * transcript_format exhaustiveness and python idiomatic assert_never * format_timestamp_mmss clear type definition * Rename seconds_to_timestamp * Test transcript format with overlapping speakers * exact match for vtt multispeaker test --------- Co-authored-by: Sergey Mankovsky Co-authored-by: Igor Loskutov --- docs/transcript.md | 241 ++++++++ .../reflector/schemas/transcript_formats.py | 17 + .../reflector/services/transcript_process.py | 5 +- server/reflector/utils/match.py | 10 - server/reflector/utils/transcript_formats.py | 125 ++++ server/reflector/utils/webvtt.py | 6 +- server/reflector/views/transcripts.py | 158 ++++- server/reflector/views/transcripts_process.py | 5 +- server/tests/test_transcript_formats.py | 575 ++++++++++++++++++ www/app/(app)/transcripts/shareCopy.tsx | 130 +++- www/app/lib/authBackend.ts | 5 + www/app/reflector-api.d.ts | 486 ++++++++++++--- 12 files changed, 1625 insertions(+), 138 deletions(-) create mode 100644 docs/transcript.md create mode 100644 server/reflector/schemas/transcript_formats.py delete mode 100644 server/reflector/utils/match.py create mode 100644 server/reflector/utils/transcript_formats.py create mode 100644 server/tests/test_transcript_formats.py diff --git a/docs/transcript.md b/docs/transcript.md new file mode 100644 index 00000000..df091aa1 --- /dev/null +++ b/docs/transcript.md @@ -0,0 +1,241 @@ +# Transcript Formats + +The Reflector API provides multiple output formats for transcript data through the `transcript_format` query parameter on the GET `/v1/transcripts/{id}` endpoint. + +## Overview + +When retrieving a transcript, you can specify the desired format using the `transcript_format` query parameter. The API supports four formats optimized for different use cases: + +- **text** - Plain text with speaker names (default) +- **text-timestamped** - Timestamped text with speaker names +- **webvtt-named** - WebVTT subtitle format with participant names +- **json** - Structured JSON segments with full metadata + +All formats include participant information when available, resolving speaker IDs to actual names. + +## Query Parameter Usage + +``` +GET /v1/transcripts/{id}?transcript_format={format} +``` + +### Parameters + +- `transcript_format` (optional): The desired output format + - Type: `"text" | "text-timestamped" | "webvtt-named" | "json"` + - Default: `"text"` + +## Format Descriptions + +### Text Format (`text`) + +**Use case:** Simple, human-readable transcript for display or export. + +**Format:** Speaker names followed by their dialogue, one line per segment. + +**Example:** +``` +John Smith: Hello everyone +Jane Doe: Hi there +John Smith: How are you today? +``` + +**Request:** +```bash +GET /v1/transcripts/{id}?transcript_format=text +``` + +**Response:** +```json +{ + "id": "transcript_123", + "name": "Meeting Recording", + "transcript_format": "text", + "transcript": "John Smith: Hello everyone\nJane Doe: Hi there\nJohn Smith: How are you today?", + "participants": [ + {"id": "p1", "speaker": 0, "name": "John Smith"}, + {"id": "p2", "speaker": 1, "name": "Jane Doe"} + ], + ... +} +``` + +### Text Timestamped Format (`text-timestamped`) + +**Use case:** Transcript with timing information for navigation or reference. + +**Format:** `[MM:SS]` timestamp prefix before each speaker and dialogue. + +**Example:** +``` +[00:00] John Smith: Hello everyone +[00:05] Jane Doe: Hi there +[00:12] John Smith: How are you today? +``` + +**Request:** +```bash +GET /v1/transcripts/{id}?transcript_format=text-timestamped +``` + +**Response:** +```json +{ + "id": "transcript_123", + "name": "Meeting Recording", + "transcript_format": "text-timestamped", + "transcript": "[00:00] John Smith: Hello everyone\n[00:05] Jane Doe: Hi there\n[00:12] John Smith: How are you today?", + "participants": [ + {"id": "p1", "speaker": 0, "name": "John Smith"}, + {"id": "p2", "speaker": 1, "name": "Jane Doe"} + ], + ... +} +``` + +### WebVTT Named Format (`webvtt-named`) + +**Use case:** Subtitle files for video players, accessibility tools, or video editing. + +**Format:** Standard WebVTT subtitle format with voice tags using participant names. + +**Example:** +``` +WEBVTT + +00:00:00.000 --> 00:00:05.000 +Hello everyone + +00:00:05.000 --> 00:00:12.000 +Hi there + +00:00:12.000 --> 00:00:18.000 +How are you today? +``` + +**Request:** +```bash +GET /v1/transcripts/{id}?transcript_format=webvtt-named +``` + +**Response:** +```json +{ + "id": "transcript_123", + "name": "Meeting Recording", + "transcript_format": "webvtt-named", + "transcript": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nHello everyone\n\n...", + "participants": [ + {"id": "p1", "speaker": 0, "name": "John Smith"}, + {"id": "p2", "speaker": 1, "name": "Jane Doe"} + ], + ... +} +``` + +### JSON Format (`json`) + +**Use case:** Programmatic access with full timing and speaker metadata. + +**Format:** Array of segment objects with speaker information, text content, and precise timing. + +**Example:** +```json +[ + { + "speaker": 0, + "speaker_name": "John Smith", + "text": "Hello everyone", + "start": 0.0, + "end": 5.0 + }, + { + "speaker": 1, + "speaker_name": "Jane Doe", + "text": "Hi there", + "start": 5.0, + "end": 12.0 + }, + { + "speaker": 0, + "speaker_name": "John Smith", + "text": "How are you today?", + "start": 12.0, + "end": 18.0 + } +] +``` + +**Request:** +```bash +GET /v1/transcripts/{id}?transcript_format=json +``` + +**Response:** +```json +{ + "id": "transcript_123", + "name": "Meeting Recording", + "transcript_format": "json", + "transcript": [ + { + "speaker": 0, + "speaker_name": "John Smith", + "text": "Hello everyone", + "start": 0.0, + "end": 5.0 + }, + { + "speaker": 1, + "speaker_name": "Jane Doe", + "text": "Hi there", + "start": 5.0, + "end": 12.0 + } + ], + "participants": [ + {"id": "p1", "speaker": 0, "name": "John Smith"}, + {"id": "p2", "speaker": 1, "name": "Jane Doe"} + ], + ... +} +``` + +## Response Structure + +All formats return the same base transcript metadata with an additional `transcript_format` field and format-specific `transcript` field: + +### Common Fields + +- `id`: Transcript identifier +- `user_id`: Owner user ID (if authenticated) +- `name`: Transcript name +- `status`: Processing status +- `locked`: Whether transcript is locked for editing +- `duration`: Total duration in seconds +- `title`: Auto-generated or custom title +- `short_summary`: Brief summary +- `long_summary`: Detailed summary +- `created_at`: Creation timestamp +- `share_mode`: Access control setting +- `source_language`: Original audio language +- `target_language`: Translation target language +- `reviewed`: Whether transcript has been reviewed +- `meeting_id`: Associated meeting ID (if applicable) +- `source_kind`: Source type (live, file, room) +- `room_id`: Associated room ID (if applicable) +- `audio_deleted`: Whether audio has been deleted +- `participants`: Array of participant objects with speaker mappings + +### Format-Specific Fields + +- `transcript_format`: The format identifier (discriminator field) +- `transcript`: The formatted transcript content (string for text/webvtt formats, array for json format) + +## Speaker Name Resolution + +All formats resolve speaker IDs to participant names when available: + +- If a participant exists for the speaker ID, their name is used +- If no participant exists, a default name like "Speaker 0" is generated +- Speaker IDs are integers (0, 1, 2, etc.) assigned during diarization diff --git a/server/reflector/schemas/transcript_formats.py b/server/reflector/schemas/transcript_formats.py new file mode 100644 index 00000000..916e4a80 --- /dev/null +++ b/server/reflector/schemas/transcript_formats.py @@ -0,0 +1,17 @@ +"""Schema definitions for transcript format types and segments.""" + +from typing import Literal + +from pydantic import BaseModel + +TranscriptFormat = Literal["text", "text-timestamped", "webvtt-named", "json"] + + +class TranscriptSegment(BaseModel): + """A single transcript segment with speaker and timing information.""" + + speaker: int + speaker_name: str + text: str + start: float + end: float diff --git a/server/reflector/services/transcript_process.py b/server/reflector/services/transcript_process.py index bc48a4eb..746ca3ea 100644 --- a/server/reflector/services/transcript_process.py +++ b/server/reflector/services/transcript_process.py @@ -7,7 +7,7 @@ This module provides result-based error handling that works in both contexts: """ from dataclasses import dataclass -from typing import Literal, Union +from typing import Literal, Union, assert_never import celery from celery.result import AsyncResult @@ -18,7 +18,6 @@ from reflector.pipelines.main_file_pipeline import task_pipeline_file_process from reflector.pipelines.main_multitrack_pipeline import ( task_pipeline_multitrack_process, ) -from reflector.utils.match import absurd from reflector.utils.string import NonEmptyString @@ -155,7 +154,7 @@ def dispatch_transcript_processing(config: ProcessingConfig) -> AsyncResult: elif isinstance(config, FileProcessingConfig): return task_pipeline_file_process.delay(transcript_id=config.transcript_id) else: - absurd(config) + assert_never(config) def task_is_scheduled_or_active(task_name: str, **kwargs): diff --git a/server/reflector/utils/match.py b/server/reflector/utils/match.py deleted file mode 100644 index e0f6bc53..00000000 --- a/server/reflector/utils/match.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import NoReturn - - -def assert_exhaustiveness(x: NoReturn) -> NoReturn: - """Provide an assertion at type-check time that this function is never called.""" - raise AssertionError(f"Invalid value: {x!r}") - - -def absurd(x: NoReturn) -> NoReturn: - return assert_exhaustiveness(x) diff --git a/server/reflector/utils/transcript_formats.py b/server/reflector/utils/transcript_formats.py new file mode 100644 index 00000000..4ccf8cce --- /dev/null +++ b/server/reflector/utils/transcript_formats.py @@ -0,0 +1,125 @@ +"""Utilities for converting transcript data to various output formats.""" + +import webvtt + +from reflector.db.transcripts import TranscriptParticipant, TranscriptTopic +from reflector.processors.types import ( + Transcript as ProcessorTranscript, +) +from reflector.processors.types import ( + words_to_segments, +) +from reflector.schemas.transcript_formats import TranscriptSegment +from reflector.utils.webvtt import seconds_to_timestamp + + +def get_speaker_name( + speaker: int, participants: list[TranscriptParticipant] | None +) -> str: + """Get participant name for speaker or default to 'Speaker N'.""" + if participants: + for participant in participants: + if participant.speaker == speaker: + return participant.name + return f"Speaker {speaker}" + + +def format_timestamp_mmss(seconds: float | int) -> str: + """Format seconds as MM:SS timestamp.""" + minutes = int(seconds // 60) + secs = int(seconds % 60) + return f"{minutes:02d}:{secs:02d}" + + +def transcript_to_text( + topics: list[TranscriptTopic], participants: list[TranscriptParticipant] | None +) -> str: + """Convert transcript topics to plain text with speaker names.""" + lines = [] + for topic in topics: + if not topic.words: + continue + + transcript = ProcessorTranscript(words=topic.words) + segments = transcript.as_segments() + + for segment in segments: + speaker_name = get_speaker_name(segment.speaker, participants) + text = segment.text.strip() + lines.append(f"{speaker_name}: {text}") + + return "\n".join(lines) + + +def transcript_to_text_timestamped( + topics: list[TranscriptTopic], participants: list[TranscriptParticipant] | None +) -> str: + """Convert transcript topics to timestamped text with speaker names.""" + lines = [] + for topic in topics: + if not topic.words: + continue + + transcript = ProcessorTranscript(words=topic.words) + segments = transcript.as_segments() + + for segment in segments: + speaker_name = get_speaker_name(segment.speaker, participants) + timestamp = format_timestamp_mmss(segment.start) + text = segment.text.strip() + lines.append(f"[{timestamp}] {speaker_name}: {text}") + + return "\n".join(lines) + + +def topics_to_webvtt_named( + topics: list[TranscriptTopic], participants: list[TranscriptParticipant] | None +) -> str: + """Convert transcript topics to WebVTT format with participant names.""" + vtt = webvtt.WebVTT() + + for topic in topics: + if not topic.words: + continue + + segments = words_to_segments(topic.words) + + for segment in segments: + speaker_name = get_speaker_name(segment.speaker, participants) + text = segment.text.strip() + text = f"{text}" + + caption = webvtt.Caption( + start=seconds_to_timestamp(segment.start), + end=seconds_to_timestamp(segment.end), + text=text, + ) + vtt.captions.append(caption) + + return vtt.content + + +def transcript_to_json_segments( + topics: list[TranscriptTopic], participants: list[TranscriptParticipant] | None +) -> list[TranscriptSegment]: + """Convert transcript topics to a flat list of JSON segments.""" + segments = [] + + for topic in topics: + if not topic.words: + continue + + transcript = ProcessorTranscript(words=topic.words) + for segment in transcript.as_segments(): + speaker_name = get_speaker_name(segment.speaker, participants) + segments.append( + TranscriptSegment( + speaker=segment.speaker, + speaker_name=speaker_name, + text=segment.text.strip(), + start=segment.start, + end=segment.end, + ) + ) + + return segments diff --git a/server/reflector/utils/webvtt.py b/server/reflector/utils/webvtt.py index efdbe948..9b3d16ef 100644 --- a/server/reflector/utils/webvtt.py +++ b/server/reflector/utils/webvtt.py @@ -13,7 +13,7 @@ VttTimestamp = Annotated[str, "vtt_timestamp"] WebVTTStr = Annotated[str, "webvtt_str"] -def _seconds_to_timestamp(seconds: Seconds) -> VttTimestamp: +def seconds_to_timestamp(seconds: Seconds) -> VttTimestamp: # lib doesn't do that hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) @@ -37,8 +37,8 @@ def words_to_webvtt(words: list[Word]) -> WebVTTStr: text = f"{text}" caption = webvtt.Caption( - start=_seconds_to_timestamp(segment.start), - end=_seconds_to_timestamp(segment.end), + start=seconds_to_timestamp(segment.start), + end=seconds_to_timestamp(segment.end), text=text, ) vtt.captions.append(caption) diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 37e806cb..dc5ccdb7 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -1,11 +1,18 @@ from datetime import datetime, timedelta, timezone -from typing import Annotated, Literal, Optional +from typing import Annotated, Literal, Optional, assert_never from fastapi import APIRouter, Depends, HTTPException, Query from fastapi_pagination import Page from fastapi_pagination.ext.databases import apaginate from jose import jwt -from pydantic import AwareDatetime, BaseModel, Field, constr, field_serializer +from pydantic import ( + AwareDatetime, + BaseModel, + Discriminator, + Field, + constr, + field_serializer, +) import reflector.auth as auth from reflector.db import get_database @@ -31,7 +38,14 @@ from reflector.db.transcripts import ( ) from reflector.processors.types import Transcript as ProcessorTranscript from reflector.processors.types import Word +from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment from reflector.settings import settings +from reflector.utils.transcript_formats import ( + topics_to_webvtt_named, + transcript_to_json_segments, + transcript_to_text, + transcript_to_text_timestamped, +) from reflector.ws_manager import get_ws_manager from reflector.zulip import ( InvalidMessageError, @@ -88,10 +102,84 @@ class GetTranscriptMinimal(BaseModel): audio_deleted: bool | None = None -class GetTranscript(GetTranscriptMinimal): +class GetTranscriptWithParticipants(GetTranscriptMinimal): participants: list[TranscriptParticipant] | None +class GetTranscriptWithText(GetTranscriptWithParticipants): + """ + Transcript response with plain text format. + + Format: Speaker names followed by their dialogue, one line per segment. + Example: + John Smith: Hello everyone + Jane Doe: Hi there + """ + + transcript_format: Literal["text"] = "text" + transcript: str + + +class GetTranscriptWithTextTimestamped(GetTranscriptWithParticipants): + """ + Transcript response with timestamped text format. + + Format: [MM:SS] timestamp prefix before each speaker and dialogue. + Example: + [00:00] John Smith: Hello everyone + [00:05] Jane Doe: Hi there + """ + + transcript_format: Literal["text-timestamped"] = "text-timestamped" + transcript: str + + +class GetTranscriptWithWebVTTNamed(GetTranscriptWithParticipants): + """ + Transcript response in WebVTT subtitle format with participant names. + + Format: Standard WebVTT with voice tags using participant names. + Example: + WEBVTT + + 00:00:00.000 --> 00:00:05.000 + Hello everyone + """ + + transcript_format: Literal["webvtt-named"] = "webvtt-named" + transcript: str + + +class GetTranscriptWithJSON(GetTranscriptWithParticipants): + """ + Transcript response as structured JSON segments. + + Format: Array of segment objects with speaker info, text, and timing. + Example: + [ + { + "speaker": 0, + "speaker_name": "John Smith", + "text": "Hello everyone", + "start": 0.0, + "end": 5.0 + } + ] + """ + + transcript_format: Literal["json"] = "json" + transcript: list[TranscriptSegment] + + +GetTranscript = Annotated[ + GetTranscriptWithText + | GetTranscriptWithTextTimestamped + | GetTranscriptWithWebVTTNamed + | GetTranscriptWithJSON, + Discriminator("transcript_format"), +] + + class CreateTranscript(BaseModel): name: str source_language: str = Field("en") @@ -228,7 +316,7 @@ async def transcripts_search( ) -@router.post("/transcripts", response_model=GetTranscript) +@router.post("/transcripts", response_model=GetTranscriptWithParticipants) async def transcripts_create( info: CreateTranscript, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], @@ -362,14 +450,72 @@ class GetTranscriptTopicWithWordsPerSpeaker(GetTranscriptTopic): async def transcript_get( transcript_id: str, user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], + transcript_format: TranscriptFormat = "text", ): user_id = user["sub"] if user else None - return await transcripts_controller.get_by_id_for_http( + transcript = await transcripts_controller.get_by_id_for_http( transcript_id, user_id=user_id ) + base_data = { + "id": transcript.id, + "user_id": transcript.user_id, + "name": transcript.name, + "status": transcript.status, + "locked": transcript.locked, + "duration": transcript.duration, + "title": transcript.title, + "short_summary": transcript.short_summary, + "long_summary": transcript.long_summary, + "created_at": transcript.created_at, + "share_mode": transcript.share_mode, + "source_language": transcript.source_language, + "target_language": transcript.target_language, + "reviewed": transcript.reviewed, + "meeting_id": transcript.meeting_id, + "source_kind": transcript.source_kind, + "room_id": transcript.room_id, + "audio_deleted": transcript.audio_deleted, + "participants": transcript.participants, + } -@router.patch("/transcripts/{transcript_id}", response_model=GetTranscript) + if transcript_format == "text": + return GetTranscriptWithText( + **base_data, + transcript_format="text", + transcript=transcript_to_text(transcript.topics, transcript.participants), + ) + elif transcript_format == "text-timestamped": + return GetTranscriptWithTextTimestamped( + **base_data, + transcript_format="text-timestamped", + transcript=transcript_to_text_timestamped( + transcript.topics, transcript.participants + ), + ) + elif transcript_format == "webvtt-named": + return GetTranscriptWithWebVTTNamed( + **base_data, + transcript_format="webvtt-named", + transcript=topics_to_webvtt_named( + transcript.topics, transcript.participants + ), + ) + elif transcript_format == "json": + return GetTranscriptWithJSON( + **base_data, + transcript_format="json", + transcript=transcript_to_json_segments( + transcript.topics, transcript.participants + ), + ) + else: + assert_never(transcript_format) + + +@router.patch( + "/transcripts/{transcript_id}", response_model=GetTranscriptWithParticipants +) async def transcript_update( transcript_id: str, info: UpdateTranscript, diff --git a/server/reflector/views/transcripts_process.py b/server/reflector/views/transcripts_process.py index 88f11e71..927cc8a9 100644 --- a/server/reflector/views/transcripts_process.py +++ b/server/reflector/views/transcripts_process.py @@ -1,4 +1,4 @@ -from typing import Annotated, Optional +from typing import Annotated, Optional, assert_never from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel @@ -15,7 +15,6 @@ from reflector.services.transcript_process import ( prepare_transcript_processing, validate_transcript_for_processing, ) -from reflector.utils.match import absurd router = APIRouter() @@ -44,7 +43,7 @@ async def transcript_process( elif isinstance(validation, ValidationOk): pass else: - absurd(validation) + assert_never(validation) config = await prepare_transcript_processing(validation) diff --git a/server/tests/test_transcript_formats.py b/server/tests/test_transcript_formats.py new file mode 100644 index 00000000..62e382fe --- /dev/null +++ b/server/tests/test_transcript_formats.py @@ -0,0 +1,575 @@ +"""Tests for transcript format conversion functionality.""" + +import pytest + +from reflector.db.transcripts import TranscriptParticipant, TranscriptTopic +from reflector.processors.types import Word +from reflector.utils.transcript_formats import ( + format_timestamp_mmss, + get_speaker_name, + topics_to_webvtt_named, + transcript_to_json_segments, + transcript_to_text, + transcript_to_text_timestamped, +) + + +@pytest.mark.asyncio +async def test_get_speaker_name_with_participants(): + """Test speaker name resolution with participants list.""" + participants = [ + TranscriptParticipant(id="1", speaker=0, name="John Smith"), + TranscriptParticipant(id="2", speaker=1, name="Jane Doe"), + ] + + assert get_speaker_name(0, participants) == "John Smith" + assert get_speaker_name(1, participants) == "Jane Doe" + assert get_speaker_name(2, participants) == "Speaker 2" + + +@pytest.mark.asyncio +async def test_get_speaker_name_without_participants(): + """Test speaker name resolution without participants list.""" + assert get_speaker_name(0, None) == "Speaker 0" + assert get_speaker_name(1, None) == "Speaker 1" + assert get_speaker_name(5, []) == "Speaker 5" + + +@pytest.mark.asyncio +async def test_format_timestamp_mmss(): + """Test timestamp formatting to MM:SS.""" + assert format_timestamp_mmss(0) == "00:00" + assert format_timestamp_mmss(5) == "00:05" + assert format_timestamp_mmss(65) == "01:05" + assert format_timestamp_mmss(125.7) == "02:05" + assert format_timestamp_mmss(3661) == "61:01" + + +@pytest.mark.asyncio +async def test_transcript_to_text(): + """Test plain text format conversion.""" + topics = [ + TranscriptTopic( + id="1", + title="Topic 1", + summary="Summary 1", + timestamp=0.0, + words=[ + Word(text="Hello", start=0.0, end=1.0, speaker=0), + Word(text=" world.", start=1.0, end=2.0, speaker=0), + ], + ), + TranscriptTopic( + id="2", + title="Topic 2", + summary="Summary 2", + timestamp=2.0, + words=[ + Word(text="How", start=2.0, end=3.0, speaker=1), + Word(text=" are", start=3.0, end=4.0, speaker=1), + Word(text=" you?", start=4.0, end=5.0, speaker=1), + ], + ), + ] + + participants = [ + TranscriptParticipant(id="1", speaker=0, name="John Smith"), + TranscriptParticipant(id="2", speaker=1, name="Jane Doe"), + ] + + result = transcript_to_text(topics, participants) + lines = result.split("\n") + + assert len(lines) == 2 + assert lines[0] == "John Smith: Hello world." + assert lines[1] == "Jane Doe: How are you?" + + +@pytest.mark.asyncio +async def test_transcript_to_text_timestamped(): + """Test timestamped text format conversion.""" + topics = [ + TranscriptTopic( + id="1", + title="Topic 1", + summary="Summary 1", + timestamp=0.0, + words=[ + Word(text="Hello", start=0.0, end=1.0, speaker=0), + Word(text=" world.", start=1.0, end=2.0, speaker=0), + ], + ), + TranscriptTopic( + id="2", + title="Topic 2", + summary="Summary 2", + timestamp=65.0, + words=[ + Word(text="How", start=65.0, end=66.0, speaker=1), + Word(text=" are", start=66.0, end=67.0, speaker=1), + Word(text=" you?", start=67.0, end=68.0, speaker=1), + ], + ), + ] + + participants = [ + TranscriptParticipant(id="1", speaker=0, name="John Smith"), + TranscriptParticipant(id="2", speaker=1, name="Jane Doe"), + ] + + result = transcript_to_text_timestamped(topics, participants) + lines = result.split("\n") + + assert len(lines) == 2 + assert lines[0] == "[00:00] John Smith: Hello world." + assert lines[1] == "[01:05] Jane Doe: How are you?" + + +@pytest.mark.asyncio +async def test_topics_to_webvtt_named(): + """Test WebVTT format conversion with participant names.""" + topics = [ + TranscriptTopic( + id="1", + title="Topic 1", + summary="Summary 1", + timestamp=0.0, + words=[ + Word(text="Hello", start=0.0, end=1.0, speaker=0), + Word(text=" world.", start=1.0, end=2.0, speaker=0), + ], + ), + ] + + participants = [ + TranscriptParticipant(id="1", speaker=0, name="John Smith"), + ] + + result = topics_to_webvtt_named(topics, participants) + + assert result.startswith("WEBVTT") + assert "" in result + assert "00:00:00.000 --> 00:00:02.000" in result + assert "Hello world." in result + + +@pytest.mark.asyncio +async def test_transcript_to_json_segments(): + """Test JSON segments format conversion.""" + topics = [ + TranscriptTopic( + id="1", + title="Topic 1", + summary="Summary 1", + timestamp=0.0, + words=[ + Word(text="Hello", start=0.0, end=1.0, speaker=0), + Word(text=" world.", start=1.0, end=2.0, speaker=0), + ], + ), + TranscriptTopic( + id="2", + title="Topic 2", + summary="Summary 2", + timestamp=2.0, + words=[ + Word(text="How", start=2.0, end=3.0, speaker=1), + Word(text=" are", start=3.0, end=4.0, speaker=1), + Word(text=" you?", start=4.0, end=5.0, speaker=1), + ], + ), + ] + + participants = [ + TranscriptParticipant(id="1", speaker=0, name="John Smith"), + TranscriptParticipant(id="2", speaker=1, name="Jane Doe"), + ] + + result = transcript_to_json_segments(topics, participants) + + assert len(result) == 2 + assert result[0].speaker == 0 + assert result[0].speaker_name == "John Smith" + assert result[0].text == "Hello world." + assert result[0].start == 0.0 + assert result[0].end == 2.0 + + assert result[1].speaker == 1 + assert result[1].speaker_name == "Jane Doe" + assert result[1].text == "How are you?" + assert result[1].start == 2.0 + assert result[1].end == 5.0 + + +@pytest.mark.asyncio +async def test_transcript_formats_with_empty_topics(): + """Test format conversion with empty topics list.""" + topics = [] + participants = [] + + assert transcript_to_text(topics, participants) == "" + assert transcript_to_text_timestamped(topics, participants) == "" + assert "WEBVTT" in topics_to_webvtt_named(topics, participants) + assert transcript_to_json_segments(topics, participants) == [] + + +@pytest.mark.asyncio +async def test_transcript_formats_with_empty_words(): + """Test format conversion with topics containing no words.""" + topics = [ + TranscriptTopic( + id="1", + title="Topic 1", + summary="Summary 1", + timestamp=0.0, + words=[], + ), + ] + participants = [] + + assert transcript_to_text(topics, participants) == "" + assert transcript_to_text_timestamped(topics, participants) == "" + assert "WEBVTT" in topics_to_webvtt_named(topics, participants) + assert transcript_to_json_segments(topics, participants) == [] + + +@pytest.mark.asyncio +async def test_transcript_formats_with_multiple_speakers(): + """Test format conversion with multiple speaker changes.""" + topics = [ + TranscriptTopic( + id="1", + title="Topic 1", + summary="Summary 1", + timestamp=0.0, + words=[ + Word(text="Hello", start=0.0, end=1.0, speaker=0), + Word(text=" there.", start=1.0, end=2.0, speaker=0), + Word(text="Hi", start=2.0, end=3.0, speaker=1), + Word(text=" back.", start=3.0, end=4.0, speaker=1), + Word(text="Good", start=4.0, end=5.0, speaker=0), + Word(text=" morning.", start=5.0, end=6.0, speaker=0), + ], + ), + ] + + participants = [ + TranscriptParticipant(id="1", speaker=0, name="Alice"), + TranscriptParticipant(id="2", speaker=1, name="Bob"), + ] + + text_result = transcript_to_text(topics, participants) + lines = text_result.split("\n") + assert len(lines) == 3 + assert "Alice: Hello there." in lines[0] + assert "Bob: Hi back." in lines[1] + assert "Alice: Good morning." in lines[2] + + json_result = transcript_to_json_segments(topics, participants) + assert len(json_result) == 3 + assert json_result[0].speaker_name == "Alice" + assert json_result[1].speaker_name == "Bob" + assert json_result[2].speaker_name == "Alice" + + +@pytest.mark.asyncio +async def test_transcript_formats_with_overlapping_speakers(): + """Test format conversion when multiple speakers speak at the same time (overlapping timestamps).""" + topics = [ + TranscriptTopic( + id="1", + title="Topic 1", + summary="Summary 1", + timestamp=0.0, + words=[ + Word(text="Hello", start=0.0, end=0.5, speaker=0), + Word(text=" there.", start=0.5, end=1.0, speaker=0), + # Speaker 1 overlaps with speaker 0 at 0.5-1.0 + Word(text="I'm", start=0.5, end=1.0, speaker=1), + Word(text=" good.", start=1.0, end=1.5, speaker=1), + ], + ), + ] + + participants = [ + TranscriptParticipant(id="1", speaker=0, name="Alice"), + TranscriptParticipant(id="2", speaker=1, name="Bob"), + ] + + text_result = transcript_to_text(topics, participants) + lines = text_result.split("\n") + assert len(lines) >= 2 + assert any("Alice:" in line for line in lines) + assert any("Bob:" in line for line in lines) + + timestamped_result = transcript_to_text_timestamped(topics, participants) + timestamped_lines = timestamped_result.split("\n") + assert len(timestamped_lines) >= 2 + assert any("Alice:" in line for line in timestamped_lines) + assert any("Bob:" in line for line in timestamped_lines) + assert any("[00:00]" in line for line in timestamped_lines) + + webvtt_result = topics_to_webvtt_named(topics, participants) + expected_webvtt = """WEBVTT + +00:00:00.000 --> 00:00:01.000 +Hello there. + +00:00:00.500 --> 00:00:01.500 +I'm good. +""" + assert webvtt_result == expected_webvtt + + segments = transcript_to_json_segments(topics, participants) + assert len(segments) >= 2 + speakers = {seg.speaker for seg in segments} + assert 0 in speakers and 1 in speakers + + alice_seg = next(seg for seg in segments if seg.speaker == 0) + bob_seg = next(seg for seg in segments if seg.speaker == 1) + + # Verify timestamps overlap: Alice (0.0-1.0) and Bob (0.5-1.5) overlap at 0.5-1.0 + assert alice_seg.start < bob_seg.end, "Alice segment should start before Bob ends" + assert bob_seg.start < alice_seg.end, "Bob segment should start before Alice ends" + + overlap_start = max(alice_seg.start, bob_seg.start) + overlap_end = min(alice_seg.end, bob_seg.end) + assert ( + overlap_start < overlap_end + ), f"Segments should overlap between {overlap_start} and {overlap_end}" + + +@pytest.mark.asyncio +async def test_api_transcript_format_text(client): + """Test GET /transcripts/{id} with transcript_format=text.""" + response = await client.post("/transcripts", json={"name": "Test transcript"}) + assert response.status_code == 200 + tid = response.json()["id"] + + from reflector.db.transcripts import ( + TranscriptParticipant, + TranscriptTopic, + transcripts_controller, + ) + from reflector.processors.types import Word + + transcript = await transcripts_controller.get_by_id(tid) + + await transcripts_controller.update( + transcript, + { + "participants": [ + TranscriptParticipant( + id="1", speaker=0, name="John Smith" + ).model_dump(), + TranscriptParticipant(id="2", speaker=1, name="Jane Doe").model_dump(), + ] + }, + ) + + await transcripts_controller.upsert_topic( + transcript, + TranscriptTopic( + title="Topic 1", + summary="Summary 1", + timestamp=0, + words=[ + Word(text="Hello", start=0, end=1, speaker=0), + Word(text=" world.", start=1, end=2, speaker=0), + ], + ), + ) + + response = await client.get(f"/transcripts/{tid}?transcript_format=text") + assert response.status_code == 200 + data = response.json() + + assert data["transcript_format"] == "text" + assert "transcript" in data + assert "John Smith: Hello world." in data["transcript"] + + +@pytest.mark.asyncio +async def test_api_transcript_format_text_timestamped(client): + """Test GET /transcripts/{id} with transcript_format=text-timestamped.""" + response = await client.post("/transcripts", json={"name": "Test transcript"}) + assert response.status_code == 200 + tid = response.json()["id"] + + from reflector.db.transcripts import ( + TranscriptParticipant, + TranscriptTopic, + transcripts_controller, + ) + from reflector.processors.types import Word + + transcript = await transcripts_controller.get_by_id(tid) + + await transcripts_controller.update( + transcript, + { + "participants": [ + TranscriptParticipant( + id="1", speaker=0, name="John Smith" + ).model_dump(), + ] + }, + ) + + await transcripts_controller.upsert_topic( + transcript, + TranscriptTopic( + title="Topic 1", + summary="Summary 1", + timestamp=0, + words=[ + Word(text="Hello", start=65, end=66, speaker=0), + Word(text=" world.", start=66, end=67, speaker=0), + ], + ), + ) + + response = await client.get( + f"/transcripts/{tid}?transcript_format=text-timestamped" + ) + assert response.status_code == 200 + data = response.json() + + assert data["transcript_format"] == "text-timestamped" + assert "transcript" in data + assert "[01:05] John Smith: Hello world." in data["transcript"] + + +@pytest.mark.asyncio +async def test_api_transcript_format_webvtt_named(client): + """Test GET /transcripts/{id} with transcript_format=webvtt-named.""" + response = await client.post("/transcripts", json={"name": "Test transcript"}) + assert response.status_code == 200 + tid = response.json()["id"] + + from reflector.db.transcripts import ( + TranscriptParticipant, + TranscriptTopic, + transcripts_controller, + ) + from reflector.processors.types import Word + + transcript = await transcripts_controller.get_by_id(tid) + + await transcripts_controller.update( + transcript, + { + "participants": [ + TranscriptParticipant( + id="1", speaker=0, name="John Smith" + ).model_dump(), + ] + }, + ) + + await transcripts_controller.upsert_topic( + transcript, + TranscriptTopic( + title="Topic 1", + summary="Summary 1", + timestamp=0, + words=[ + Word(text="Hello", start=0, end=1, speaker=0), + Word(text=" world.", start=1, end=2, speaker=0), + ], + ), + ) + + response = await client.get(f"/transcripts/{tid}?transcript_format=webvtt-named") + assert response.status_code == 200 + data = response.json() + + assert data["transcript_format"] == "webvtt-named" + assert "transcript" in data + assert "WEBVTT" in data["transcript"] + assert "" in data["transcript"] + + +@pytest.mark.asyncio +async def test_api_transcript_format_json(client): + """Test GET /transcripts/{id} with transcript_format=json.""" + response = await client.post("/transcripts", json={"name": "Test transcript"}) + assert response.status_code == 200 + tid = response.json()["id"] + + from reflector.db.transcripts import ( + TranscriptParticipant, + TranscriptTopic, + transcripts_controller, + ) + from reflector.processors.types import Word + + transcript = await transcripts_controller.get_by_id(tid) + + await transcripts_controller.update( + transcript, + { + "participants": [ + TranscriptParticipant( + id="1", speaker=0, name="John Smith" + ).model_dump(), + ] + }, + ) + + await transcripts_controller.upsert_topic( + transcript, + TranscriptTopic( + title="Topic 1", + summary="Summary 1", + timestamp=0, + words=[ + Word(text="Hello", start=0, end=1, speaker=0), + Word(text=" world.", start=1, end=2, speaker=0), + ], + ), + ) + + response = await client.get(f"/transcripts/{tid}?transcript_format=json") + assert response.status_code == 200 + data = response.json() + + assert data["transcript_format"] == "json" + assert "transcript" in data + assert isinstance(data["transcript"], list) + assert len(data["transcript"]) == 1 + assert data["transcript"][0]["speaker"] == 0 + assert data["transcript"][0]["speaker_name"] == "John Smith" + assert data["transcript"][0]["text"] == "Hello world." + + +@pytest.mark.asyncio +async def test_api_transcript_format_default_is_text(client): + """Test GET /transcripts/{id} defaults to text format.""" + response = await client.post("/transcripts", json={"name": "Test transcript"}) + assert response.status_code == 200 + tid = response.json()["id"] + + from reflector.db.transcripts import TranscriptTopic, transcripts_controller + from reflector.processors.types import Word + + transcript = await transcripts_controller.get_by_id(tid) + + await transcripts_controller.upsert_topic( + transcript, + TranscriptTopic( + title="Topic 1", + summary="Summary 1", + timestamp=0, + words=[ + Word(text="Hello", start=0, end=1, speaker=0), + ], + ), + ) + + response = await client.get(f"/transcripts/{tid}") + assert response.status_code == 200 + data = response.json() + + assert data["transcript_format"] == "text" + assert "transcript" in data diff --git a/www/app/(app)/transcripts/shareCopy.tsx b/www/app/(app)/transcripts/shareCopy.tsx index bdbff5f4..e18b5ab7 100644 --- a/www/app/(app)/transcripts/shareCopy.tsx +++ b/www/app/(app)/transcripts/shareCopy.tsx @@ -1,14 +1,16 @@ import { useState } from "react"; -import type { components } from "../../reflector-api"; -type GetTranscript = components["schemas"]["GetTranscript"]; +import type { components, operations } from "../../reflector-api"; +type GetTranscriptWithParticipants = + components["schemas"]["GetTranscriptWithParticipants"]; type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; -import { Button, BoxProps, Box } from "@chakra-ui/react"; -import { buildTranscriptWithTopics } from "./buildTranscriptWithTopics"; -import { useTranscriptParticipants } from "../../lib/apiHooks"; +import { Button, BoxProps, Box, Menu, Text } from "@chakra-ui/react"; +import { LuChevronDown } from "react-icons/lu"; +import { client } from "../../lib/apiClient"; +import { toaster } from "../../components/ui/toaster"; type ShareCopyProps = { finalSummaryElement: HTMLDivElement | null; - transcript: GetTranscript; + transcript: GetTranscriptWithParticipants; topics: GetTranscriptTopic[]; }; @@ -20,11 +22,33 @@ export default function ShareCopy({ }: ShareCopyProps & BoxProps) { const [isCopiedSummary, setIsCopiedSummary] = useState(false); const [isCopiedTranscript, setIsCopiedTranscript] = useState(false); - const participantsQuery = useTranscriptParticipants(transcript?.id || null); + const [isCopying, setIsCopying] = useState(false); + + type ApiTranscriptFormat = NonNullable< + operations["v1_transcript_get"]["parameters"]["query"] + >["transcript_format"]; + const TRANSCRIPT_FORMATS = [ + "text", + "text-timestamped", + "webvtt-named", + "json", + ] as const satisfies ApiTranscriptFormat[]; + type TranscriptFormat = (typeof TRANSCRIPT_FORMATS)[number]; + + const TRANSCRIPT_FORMAT_LABELS: { [k in TranscriptFormat]: string } = { + text: "Plain text", + "text-timestamped": "Text + timestamps", + "webvtt-named": "WebVTT (named)", + json: "JSON", + }; + + const formatOptions = TRANSCRIPT_FORMATS.map((f) => ({ + value: f, + label: TRANSCRIPT_FORMAT_LABELS[f], + })); const onCopySummaryClick = () => { const text_to_copy = finalSummaryElement?.innerText; - if (text_to_copy) { navigator.clipboard.writeText(text_to_copy).then(() => { setIsCopiedSummary(true); @@ -34,27 +58,91 @@ export default function ShareCopy({ } }; - const onCopyTranscriptClick = () => { - const text_to_copy = - buildTranscriptWithTopics( - topics || [], - participantsQuery?.data || null, - transcript?.title || null, - ) || ""; + const onCopyTranscriptFormatClick = async (format: TranscriptFormat) => { + try { + setIsCopying(true); + const { data, error } = await client.GET( + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: transcript.id }, + query: { transcript_format: format }, + }, + }, + ); + if (error) { + console.error("Failed to copy transcript:", error); + toaster.create({ + duration: 3000, + render: () => ( + + Error + Failed to fetch transcript + + ), + }); + return; + } - text_to_copy && - navigator.clipboard.writeText(text_to_copy).then(() => { + const copiedText = + format === "json" + ? JSON.stringify(data?.transcript ?? {}, null, 2) + : String(data?.transcript ?? ""); + + if (copiedText) { + await navigator.clipboard.writeText(copiedText); setIsCopiedTranscript(true); - // Reset the copied state after 2 seconds setTimeout(() => setIsCopiedTranscript(false), 2000); + } + } catch (e) { + console.error("Failed to copy transcript:", e); + toaster.create({ + duration: 3000, + render: () => ( + + Error + Failed to copy transcript + + ), }); + } finally { + setIsCopying(false); + } }; return ( - + + + + + + + {formatOptions.map((opt) => ( + onCopyTranscriptFormatClick(opt.value)} + > + {opt.label} + + ))} + + + diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts index 7a8fa433..c28ee224 100644 --- a/www/app/lib/authBackend.ts +++ b/www/app/lib/authBackend.ts @@ -32,6 +32,11 @@ async function getUserId(accessToken: string): Promise { }); if (!response.ok) { + try { + console.error(await response.text()); + } catch (e) { + console.error("Failed to parse error response", e); + } return null; } diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts index 9b9582ba..4aa6ee36 100644 --- a/www/app/reflector-api.d.ts +++ b/www/app/reflector-api.d.ts @@ -696,7 +696,7 @@ export interface paths { patch?: never; trace?: never; }; - "/v1/webhook": { + "/v1/daily/webhook": { parameters: { query?: never; header?: never; @@ -708,6 +708,27 @@ export interface paths { /** * Webhook * @description Handle Daily webhook events. + * + * Example webhook payload: + * { + * "version": "1.0.0", + * "type": "recording.ready-to-download", + * "id": "rec-rtd-c3df927c-f738-4471-a2b7-066fa7e95a6b-1692124192", + * "payload": { + * "recording_id": "08fa0b24-9220-44c5-846c-3f116cf8e738", + * "room_name": "Xcm97xRZ08b2dePKb78g", + * "start_ts": 1692124183, + * "status": "finished", + * "max_participants": 1, + * "duration": 9, + * "share_token": "ntDCL5k98Ulq", #gitleaks:allow + * "s3_key": "api-test-1j8fizhzd30c/Xcm97xRZ08b2dePKb78g/1692124183028" + * }, + * "event_ts": 1692124192 + * } + * + * Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook + * state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py */ post: operations["v1_webhook"]; delete?: never; @@ -899,81 +920,11 @@ export interface components { target_language: string; source_kind?: components["schemas"]["SourceKind"] | null; }; - /** - * DailyWebhookEvent - * @description Daily webhook event structure. - */ - DailyWebhookEvent: { - /** Type */ - type: string; - /** Id */ - id: string; - /** Ts */ - ts: number; - /** Data */ - data: { - [key: string]: unknown; - }; - }; /** DeletionStatus */ DeletionStatus: { /** Status */ status: string; }; - /** GetTranscript */ - GetTranscript: { - /** Id */ - id: string; - /** User Id */ - user_id: string | null; - /** Name */ - name: string; - /** - * Status - * @enum {string} - */ - status: - | "idle" - | "uploaded" - | "recording" - | "processing" - | "error" - | "ended"; - /** Locked */ - locked: boolean; - /** Duration */ - duration: number; - /** Title */ - title: string | null; - /** Short Summary */ - short_summary: string | null; - /** Long Summary */ - long_summary: string | null; - /** Created At */ - created_at: string; - /** - * Share Mode - * @default private - */ - share_mode: string; - /** Source Language */ - source_language: string | null; - /** Target Language */ - target_language: string | null; - /** Reviewed */ - reviewed: boolean; - /** Meeting Id */ - meeting_id: string | null; - source_kind: components["schemas"]["SourceKind"]; - /** Room Id */ - room_id?: string | null; - /** Room Name */ - room_name?: string | null; - /** Audio Deleted */ - audio_deleted?: boolean | null; - /** Participants */ - participants: components["schemas"]["TranscriptParticipant"][] | null; - }; /** GetTranscriptMinimal */ GetTranscriptMinimal: { /** Id */ @@ -1105,6 +1056,345 @@ export interface components { */ words_per_speaker: components["schemas"]["SpeakerWords"][]; }; + /** + * GetTranscriptWithJSON + * @description Transcript response as structured JSON segments. + * + * Format: Array of segment objects with speaker info, text, and timing. + * Example: + * [ + * { + * "speaker": 0, + * "speaker_name": "John Smith", + * "text": "Hello everyone", + * "start": 0.0, + * "end": 5.0 + * } + * ] + */ + GetTranscriptWithJSON: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Participants */ + participants: components["schemas"]["TranscriptParticipant"][] | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + transcript_format: "json"; + /** Transcript */ + transcript: components["schemas"]["TranscriptSegment"][]; + }; + /** GetTranscriptWithParticipants */ + GetTranscriptWithParticipants: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Participants */ + participants: components["schemas"]["TranscriptParticipant"][] | null; + }; + /** + * GetTranscriptWithText + * @description Transcript response with plain text format. + * + * Format: Speaker names followed by their dialogue, one line per segment. + * Example: + * John Smith: Hello everyone + * Jane Doe: Hi there + */ + GetTranscriptWithText: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Participants */ + participants: components["schemas"]["TranscriptParticipant"][] | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + transcript_format: "text"; + /** Transcript */ + transcript: string; + }; + /** + * GetTranscriptWithTextTimestamped + * @description Transcript response with timestamped text format. + * + * Format: [MM:SS] timestamp prefix before each speaker and dialogue. + * Example: + * [00:00] John Smith: Hello everyone + * [00:05] Jane Doe: Hi there + */ + GetTranscriptWithTextTimestamped: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Participants */ + participants: components["schemas"]["TranscriptParticipant"][] | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + transcript_format: "text-timestamped"; + /** Transcript */ + transcript: string; + }; + /** + * GetTranscriptWithWebVTTNamed + * @description Transcript response in WebVTT subtitle format with participant names. + * + * Format: Standard WebVTT with voice tags using participant names. + * Example: + * WEBVTT + * + * 00:00:00.000 --> 00:00:05.000 + * Hello everyone + */ + GetTranscriptWithWebVTTNamed: { + /** Id */ + id: string; + /** User Id */ + user_id: string | null; + /** Name */ + name: string; + /** + * Status + * @enum {string} + */ + status: + | "idle" + | "uploaded" + | "recording" + | "processing" + | "error" + | "ended"; + /** Locked */ + locked: boolean; + /** Duration */ + duration: number; + /** Title */ + title: string | null; + /** Short Summary */ + short_summary: string | null; + /** Long Summary */ + long_summary: string | null; + /** Created At */ + created_at: string; + /** + * Share Mode + * @default private + */ + share_mode: string; + /** Source Language */ + source_language: string | null; + /** Target Language */ + target_language: string | null; + /** Reviewed */ + reviewed: boolean; + /** Meeting Id */ + meeting_id: string | null; + source_kind: components["schemas"]["SourceKind"]; + /** Room Id */ + room_id?: string | null; + /** Room Name */ + room_name?: string | null; + /** Audio Deleted */ + audio_deleted?: boolean | null; + /** Participants */ + participants: components["schemas"]["TranscriptParticipant"][] | null; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + transcript_format: "webvtt-named"; + /** Transcript */ + transcript: string; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -1233,7 +1523,6 @@ export interface components { } | null; /** * Platform - * @default whereby * @enum {string} */ platform: "whereby" | "daily"; @@ -1325,7 +1614,6 @@ export interface components { ics_last_etag?: string | null; /** * Platform - * @default whereby * @enum {string} */ platform: "whereby" | "daily"; @@ -1377,7 +1665,6 @@ export interface components { ics_last_etag?: string | null; /** * Platform - * @default whereby * @enum {string} */ platform: "whereby" | "daily"; @@ -1523,6 +1810,24 @@ export interface components { speaker: number | null; /** Name */ name: string; + /** User Id */ + user_id?: string | null; + }; + /** + * TranscriptSegment + * @description A single transcript segment with speaker and timing information. + */ + TranscriptSegment: { + /** Speaker */ + speaker: number; + /** Speaker Name */ + speaker_name: string; + /** Text */ + text: string; + /** Start */ + start: number; + /** End */ + end: number; }; /** UpdateParticipant */ UpdateParticipant: { @@ -2311,7 +2616,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetTranscript"]; + "application/json": components["schemas"]["GetTranscriptWithParticipants"]; }; }; /** @description Validation Error */ @@ -2369,7 +2674,13 @@ export interface operations { }; v1_transcript_get: { parameters: { - query?: never; + query?: { + transcript_format?: + | "text" + | "text-timestamped" + | "webvtt-named" + | "json"; + }; header?: never; path: { transcript_id: string; @@ -2384,7 +2695,11 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetTranscript"]; + "application/json": + | components["schemas"]["GetTranscriptWithText"] + | components["schemas"]["GetTranscriptWithTextTimestamped"] + | components["schemas"]["GetTranscriptWithWebVTTNamed"] + | components["schemas"]["GetTranscriptWithJSON"]; }; }; /** @description Validation Error */ @@ -2450,7 +2765,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["GetTranscript"]; + "application/json": components["schemas"]["GetTranscriptWithParticipants"]; }; }; /** @description Validation Error */ @@ -3256,11 +3571,7 @@ export interface operations { path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["DailyWebhookEvent"]; - }; - }; + requestBody?: never; responses: { /** @description Successful Response */ 200: { @@ -3271,15 +3582,6 @@ export interface operations { "application/json": unknown; }; }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; }; }; } From 8d696aa775a9de8a59951ab7f919f566e389de17 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 26 Nov 2025 12:12:02 -0600 Subject: [PATCH 11/26] chore(main): release 0.21.0 (#746) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ba42f3..923fe485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.21.0](https://github.com/Monadical-SAS/reflector/compare/v0.20.0...v0.21.0) (2025-11-26) + + +### Features + +* add transcript format parameter to GET endpoint ([#709](https://github.com/Monadical-SAS/reflector/issues/709)) ([f6ca075](https://github.com/Monadical-SAS/reflector/commit/f6ca07505f34483b02270a2ef3bd809e9d2e1045)) + ## [0.20.0](https://github.com/Monadical-SAS/reflector/compare/v0.19.0...v0.20.0) (2025-11-25) From d63040e2fdc07e7b272e85a39eb2411cd6a14798 Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Wed, 26 Nov 2025 16:21:32 -0500 Subject: [PATCH 12/26] feat: Multitrack segmentation (#747) * segmentation multitrack (no-mistakes) * segmentation multitrack (no-mistakes) * self review * self review * recording poll daily doc * filter cam_audio tracks to remove screensharing from daily processing * pr review --------- Co-authored-by: Igor Loskutov --- server/reflector/db/recordings.py | 7 + server/reflector/processors/types.py | 83 +++++- server/reflector/utils/daily.py | 5 + server/reflector/utils/transcript_formats.py | 36 ++- server/reflector/views/transcripts.py | 48 ++- server/reflector/worker/process.py | 36 ++- .../test_processor_transcript_segment.py | 75 +++++ server/tests/test_transcript_formats.py | 276 +++++++++++++++--- 8 files changed, 485 insertions(+), 81 deletions(-) diff --git a/server/reflector/db/recordings.py b/server/reflector/db/recordings.py index c67b8413..18fe358b 100644 --- a/server/reflector/db/recordings.py +++ b/server/reflector/db/recordings.py @@ -35,8 +35,15 @@ class Recording(BaseModel): status: Literal["pending", "processing", "completed", "failed"] = "pending" meeting_id: str | None = None # for multitrack reprocessing + # track_keys can be empty list [] if recording finished but no audio was captured (silence/muted) + # None means not a multitrack recording, [] means multitrack with no tracks track_keys: list[str] | None = None + @property + def is_multitrack(self) -> bool: + """True if recording has separate audio tracks (1+ tracks counts as multitrack).""" + return self.track_keys is not None and len(self.track_keys) > 0 + class RecordingController: async def create(self, recording: Recording): diff --git a/server/reflector/processors/types.py b/server/reflector/processors/types.py index 7096e81c..3369e09c 100644 --- a/server/reflector/processors/types.py +++ b/server/reflector/processors/types.py @@ -1,6 +1,7 @@ import io import re import tempfile +from collections import defaultdict from pathlib import Path from typing import Annotated, TypedDict @@ -16,6 +17,17 @@ class DiarizationSegment(TypedDict): PUNC_RE = re.compile(r"[.;:?!…]") +SENTENCE_END_RE = re.compile(r"[.?!…]$") + +# Max segment length for words_to_segments() - breaks on any punctuation (. ; : ? ! …) +# when segment exceeds this limit. Used for non-multitrack recordings. +MAX_SEGMENT_CHARS = 120 + +# Max segment length for words_to_segments_by_sentence() - only breaks on sentence-ending +# punctuation (. ? ! …) when segment exceeds this limit. Higher threshold allows complete +# sentences in multitrack recordings where speakers overlap. +# similar number to server/reflector/processors/transcript_liner.py +MAX_SENTENCE_SEGMENT_CHARS = 1000 class AudioFile(BaseModel): @@ -76,7 +88,6 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]: # but separate if the speaker changes, or if the punctuation is a . , ; : ? ! segments = [] current_segment = None - MAX_SEGMENT_LENGTH = 120 for word in words: if current_segment is None: @@ -106,7 +117,7 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]: current_segment.end = word.end have_punc = PUNC_RE.search(word.text) - if have_punc and (len(current_segment.text) > MAX_SEGMENT_LENGTH): + if have_punc and (len(current_segment.text) > MAX_SEGMENT_CHARS): segments.append(current_segment) current_segment = None @@ -116,6 +127,70 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]: return segments +def words_to_segments_by_sentence(words: list[Word]) -> list[TranscriptSegment]: + """Group words by speaker, then split into sentences. + + For multitrack recordings where words from different speakers are interleaved + by timestamp, this function first groups all words by speaker, then creates + segments based on sentence boundaries within each speaker's words. + + This produces cleaner output than words_to_segments() which breaks on every + speaker change, resulting in many tiny segments when speakers overlap. + """ + if not words: + return [] + + # Group words by speaker, preserving order within each speaker + by_speaker: dict[int, list[Word]] = defaultdict(list) + for w in words: + by_speaker[w.speaker].append(w) + + segments: list[TranscriptSegment] = [] + + for speaker, speaker_words in by_speaker.items(): + current_text = "" + current_start: float | None = None + current_end: float = 0.0 + + for word in speaker_words: + if current_start is None: + current_start = word.start + + current_text += word.text + current_end = word.end + + # Check for sentence end or max length + is_sentence_end = SENTENCE_END_RE.search(word.text.strip()) + is_too_long = len(current_text) >= MAX_SENTENCE_SEGMENT_CHARS + + if is_sentence_end or is_too_long: + segments.append( + TranscriptSegment( + text=current_text, + start=current_start, + end=current_end, + speaker=speaker, + ) + ) + current_text = "" + current_start = None + + # Flush remaining words for this speaker + if current_text and current_start is not None: + segments.append( + TranscriptSegment( + text=current_text, + start=current_start, + end=current_end, + speaker=speaker, + ) + ) + + # Sort segments by start time + segments.sort(key=lambda s: s.start) + return segments + + class Transcript(BaseModel): translation: str | None = None words: list[Word] = [] @@ -154,7 +229,9 @@ class Transcript(BaseModel): word.start += offset word.end += offset - def as_segments(self) -> list[TranscriptSegment]: + def as_segments(self, is_multitrack: bool = False) -> list[TranscriptSegment]: + if is_multitrack: + return words_to_segments_by_sentence(self.words) return words_to_segments(self.words) diff --git a/server/reflector/utils/daily.py b/server/reflector/utils/daily.py index 72242f78..91f8d782 100644 --- a/server/reflector/utils/daily.py +++ b/server/reflector/utils/daily.py @@ -64,6 +64,11 @@ def recording_lock_key(recording_id: NonEmptyString) -> NonEmptyString: return f"recording:{recording_id}" +def filter_cam_audio_tracks(track_keys: list[str]) -> list[str]: + """Filter track keys to cam-audio tracks only (skip screen-audio, etc.).""" + return [k for k in track_keys if "cam-audio" in k] + + def extract_base_room_name(daily_room_name: DailyRoomName) -> NonEmptyString: """ Extract base room name from Daily.co timestamped room name. diff --git a/server/reflector/utils/transcript_formats.py b/server/reflector/utils/transcript_formats.py index 4ccf8cce..d2aa3af2 100644 --- a/server/reflector/utils/transcript_formats.py +++ b/server/reflector/utils/transcript_formats.py @@ -6,9 +6,6 @@ from reflector.db.transcripts import TranscriptParticipant, TranscriptTopic from reflector.processors.types import ( Transcript as ProcessorTranscript, ) -from reflector.processors.types import ( - words_to_segments, -) from reflector.schemas.transcript_formats import TranscriptSegment from reflector.utils.webvtt import seconds_to_timestamp @@ -32,7 +29,9 @@ def format_timestamp_mmss(seconds: float | int) -> str: def transcript_to_text( - topics: list[TranscriptTopic], participants: list[TranscriptParticipant] | None + topics: list[TranscriptTopic], + participants: list[TranscriptParticipant] | None, + is_multitrack: bool = False, ) -> str: """Convert transcript topics to plain text with speaker names.""" lines = [] @@ -41,7 +40,7 @@ def transcript_to_text( continue transcript = ProcessorTranscript(words=topic.words) - segments = transcript.as_segments() + segments = transcript.as_segments(is_multitrack) for segment in segments: speaker_name = get_speaker_name(segment.speaker, participants) @@ -52,7 +51,9 @@ def transcript_to_text( def transcript_to_text_timestamped( - topics: list[TranscriptTopic], participants: list[TranscriptParticipant] | None + topics: list[TranscriptTopic], + participants: list[TranscriptParticipant] | None, + is_multitrack: bool = False, ) -> str: """Convert transcript topics to timestamped text with speaker names.""" lines = [] @@ -61,7 +62,7 @@ def transcript_to_text_timestamped( continue transcript = ProcessorTranscript(words=topic.words) - segments = transcript.as_segments() + segments = transcript.as_segments(is_multitrack) for segment in segments: speaker_name = get_speaker_name(segment.speaker, participants) @@ -73,7 +74,9 @@ def transcript_to_text_timestamped( def topics_to_webvtt_named( - topics: list[TranscriptTopic], participants: list[TranscriptParticipant] | None + topics: list[TranscriptTopic], + participants: list[TranscriptParticipant] | None, + is_multitrack: bool = False, ) -> str: """Convert transcript topics to WebVTT format with participant names.""" vtt = webvtt.WebVTT() @@ -82,7 +85,8 @@ def topics_to_webvtt_named( if not topic.words: continue - segments = words_to_segments(topic.words) + transcript = ProcessorTranscript(words=topic.words) + segments = transcript.as_segments(is_multitrack) for segment in segments: speaker_name = get_speaker_name(segment.speaker, participants) @@ -100,19 +104,23 @@ def topics_to_webvtt_named( def transcript_to_json_segments( - topics: list[TranscriptTopic], participants: list[TranscriptParticipant] | None + topics: list[TranscriptTopic], + participants: list[TranscriptParticipant] | None, + is_multitrack: bool = False, ) -> list[TranscriptSegment]: """Convert transcript topics to a flat list of JSON segments.""" - segments = [] + result = [] for topic in topics: if not topic.words: continue transcript = ProcessorTranscript(words=topic.words) - for segment in transcript.as_segments(): + segments = transcript.as_segments(is_multitrack) + + for segment in segments: speaker_name = get_speaker_name(segment.speaker, participants) - segments.append( + result.append( TranscriptSegment( speaker=segment.speaker, speaker_name=speaker_name, @@ -122,4 +130,4 @@ def transcript_to_json_segments( ) ) - return segments + return result diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index dc5ccdb7..625a9896 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -16,6 +16,7 @@ from pydantic import ( import reflector.auth as auth from reflector.db import get_database +from reflector.db.recordings import recordings_controller from reflector.db.search import ( DEFAULT_SEARCH_LIMIT, SearchLimit, @@ -60,6 +61,14 @@ ALGORITHM = "HS256" DOWNLOAD_EXPIRE_MINUTES = 60 +async def _get_is_multitrack(transcript) -> bool: + """Detect if transcript is from multitrack recording.""" + if not transcript.recording_id: + return False + recording = await recordings_controller.get_by_id(transcript.recording_id) + return recording is not None and recording.is_multitrack + + def create_access_token(data: dict, expires_delta: timedelta): to_encode = data.copy() expire = datetime.now(timezone.utc) + expires_delta @@ -360,7 +369,7 @@ class GetTranscriptTopic(BaseModel): segments: list[GetTranscriptSegmentTopic] = [] @classmethod - def from_transcript_topic(cls, topic: TranscriptTopic): + def from_transcript_topic(cls, topic: TranscriptTopic, is_multitrack: bool = False): if not topic.words: # In previous version, words were missing # Just output a segment with speaker 0 @@ -384,7 +393,7 @@ class GetTranscriptTopic(BaseModel): start=segment.start, speaker=segment.speaker, ) - for segment in transcript.as_segments() + for segment in transcript.as_segments(is_multitrack) ] return cls( id=topic.id, @@ -401,8 +410,8 @@ class GetTranscriptTopicWithWords(GetTranscriptTopic): words: list[Word] = [] @classmethod - def from_transcript_topic(cls, topic: TranscriptTopic): - instance = super().from_transcript_topic(topic) + def from_transcript_topic(cls, topic: TranscriptTopic, is_multitrack: bool = False): + instance = super().from_transcript_topic(topic, is_multitrack) if topic.words: instance.words = topic.words return instance @@ -417,8 +426,8 @@ class GetTranscriptTopicWithWordsPerSpeaker(GetTranscriptTopic): words_per_speaker: list[SpeakerWords] = [] @classmethod - def from_transcript_topic(cls, topic: TranscriptTopic): - instance = super().from_transcript_topic(topic) + def from_transcript_topic(cls, topic: TranscriptTopic, is_multitrack: bool = False): + instance = super().from_transcript_topic(topic, is_multitrack) if topic.words: words_per_speakers = [] # group words by speaker @@ -457,6 +466,8 @@ async def transcript_get( transcript_id, user_id=user_id ) + is_multitrack = await _get_is_multitrack(transcript) + base_data = { "id": transcript.id, "user_id": transcript.user_id, @@ -483,14 +494,16 @@ async def transcript_get( return GetTranscriptWithText( **base_data, transcript_format="text", - transcript=transcript_to_text(transcript.topics, transcript.participants), + transcript=transcript_to_text( + transcript.topics, transcript.participants, is_multitrack + ), ) elif transcript_format == "text-timestamped": return GetTranscriptWithTextTimestamped( **base_data, transcript_format="text-timestamped", transcript=transcript_to_text_timestamped( - transcript.topics, transcript.participants + transcript.topics, transcript.participants, is_multitrack ), ) elif transcript_format == "webvtt-named": @@ -498,7 +511,7 @@ async def transcript_get( **base_data, transcript_format="webvtt-named", transcript=topics_to_webvtt_named( - transcript.topics, transcript.participants + transcript.topics, transcript.participants, is_multitrack ), ) elif transcript_format == "json": @@ -506,7 +519,7 @@ async def transcript_get( **base_data, transcript_format="json", transcript=transcript_to_json_segments( - transcript.topics, transcript.participants + transcript.topics, transcript.participants, is_multitrack ), ) else: @@ -565,9 +578,12 @@ async def transcript_get_topics( transcript_id, user_id=user_id ) + is_multitrack = await _get_is_multitrack(transcript) + # convert to GetTranscriptTopic return [ - GetTranscriptTopic.from_transcript_topic(topic) for topic in transcript.topics + GetTranscriptTopic.from_transcript_topic(topic, is_multitrack) + for topic in transcript.topics ] @@ -584,9 +600,11 @@ async def transcript_get_topics_with_words( transcript_id, user_id=user_id ) + is_multitrack = await _get_is_multitrack(transcript) + # convert to GetTranscriptTopicWithWords return [ - GetTranscriptTopicWithWords.from_transcript_topic(topic) + GetTranscriptTopicWithWords.from_transcript_topic(topic, is_multitrack) for topic in transcript.topics ] @@ -605,13 +623,17 @@ async def transcript_get_topics_with_words_per_speaker( transcript_id, user_id=user_id ) + is_multitrack = await _get_is_multitrack(transcript) + # get the topic from the transcript topic = next((t for t in transcript.topics if t.id == topic_id), None) if not topic: raise HTTPException(status_code=404, detail="Topic not found") # convert to GetTranscriptTopicWithWordsPerSpeaker - return GetTranscriptTopicWithWordsPerSpeaker.from_transcript_topic(topic) + return GetTranscriptTopicWithWordsPerSpeaker.from_transcript_topic( + topic, is_multitrack + ) @router.post("/transcripts/{transcript_id}/zulip") diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index 0e1b4d86..adf73d15 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -2,6 +2,7 @@ import json import os import re from datetime import datetime, timezone +from typing import List from urllib.parse import unquote import av @@ -11,7 +12,7 @@ from celery import shared_task from celery.utils.log import get_task_logger from pydantic import ValidationError -from reflector.dailyco_api import MeetingParticipantsResponse +from reflector.dailyco_api import MeetingParticipantsResponse, RecordingResponse from reflector.db.daily_participant_sessions import ( DailyParticipantSession, daily_participant_sessions_controller, @@ -38,6 +39,7 @@ from reflector.storage import get_transcripts_storage from reflector.utils.daily import ( DailyRoomName, extract_base_room_name, + filter_cam_audio_tracks, parse_daily_recording_filename, recording_lock_key, ) @@ -338,7 +340,9 @@ async def _process_multitrack_recording_inner( exc_info=True, ) - for idx, key in enumerate(track_keys): + 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 @@ -366,7 +370,7 @@ async def _process_multitrack_recording_inner( task_pipeline_multitrack_process.delay( transcript_id=transcript.id, bucket_name=bucket_name, - track_keys=track_keys, + track_keys=filter_cam_audio_tracks(track_keys), ) @@ -391,7 +395,7 @@ async def poll_daily_recordings(): async with create_platform_client("daily") as daily_client: # latest 100. TODO cursor-based state - api_recordings = await daily_client.list_recordings() + api_recordings: List[RecordingResponse] = await daily_client.list_recordings() if not api_recordings: logger.debug( @@ -422,17 +426,19 @@ async def poll_daily_recordings(): for recording in missing_recordings: if not recording.tracks: - assert recording.status != "finished", ( - f"Recording {recording.id} has status='finished' but no tracks. " - f"Daily.co API guarantees finished recordings have tracks available. " - f"room_name={recording.room_name}" - ) - logger.debug( - "No tracks in recording yet", - recording_id=recording.id, - room_name=recording.room_name, - status=recording.status, - ) + if recording.status == "finished": + logger.warning( + "Finished recording has no tracks (no audio captured)", + recording_id=recording.id, + room_name=recording.room_name, + ) + else: + logger.debug( + "No tracks in recording yet", + recording_id=recording.id, + room_name=recording.room_name, + status=recording.status, + ) continue track_keys = [t.s3Key for t in recording.tracks if t.type == "audio"] diff --git a/server/tests/test_processor_transcript_segment.py b/server/tests/test_processor_transcript_segment.py index 6fde0dd1..89cc459f 100644 --- a/server/tests/test_processor_transcript_segment.py +++ b/server/tests/test_processor_transcript_segment.py @@ -159,3 +159,78 @@ def test_processor_transcript_segment(): assert segments[3].start == 30.72 assert segments[4].start == 31.56 assert segments[5].start == 32.38 + + +def test_processor_transcript_segment_multitrack_interleaved(): + """Test as_segments(is_multitrack=True) with interleaved speakers. + + Multitrack recordings have words from different speakers sorted by start time, + causing frequent speaker alternation. The multitrack mode should group by + speaker first, then split into sentences. + """ + from reflector.processors.types import Transcript, Word + + # Simulate real multitrack data: words sorted by start time, speakers interleave + # Speaker 0 says: "Hello there." + # Speaker 1 says: "I'm good." + # When sorted by time, words interleave + transcript = Transcript( + words=[ + Word(text="Hello ", start=0.0, end=0.5, speaker=0), + Word(text="I'm ", start=0.5, end=0.8, speaker=1), + Word(text="there.", start=0.5, end=1.0, speaker=0), + Word(text="good.", start=1.0, end=1.5, speaker=1), + ] + ) + + # Default behavior (is_multitrack=False): breaks on every speaker change = 4 segments + segments_default = transcript.as_segments(is_multitrack=False) + assert len(segments_default) == 4 + + # Multitrack behavior: groups by speaker, then sentences = 2 segments + segments_multitrack = transcript.as_segments(is_multitrack=True) + assert len(segments_multitrack) == 2 + + # Check content - sorted by start time + assert segments_multitrack[0].speaker == 0 + assert segments_multitrack[0].text == "Hello there." + assert segments_multitrack[0].start == 0.0 + assert segments_multitrack[0].end == 1.0 + + assert segments_multitrack[1].speaker == 1 + assert segments_multitrack[1].text == "I'm good." + assert segments_multitrack[1].start == 0.5 + assert segments_multitrack[1].end == 1.5 + + +def test_processor_transcript_segment_multitrack_overlapping_timestamps(): + """Test multitrack with exactly overlapping timestamps (real Daily.co data pattern).""" + from reflector.processors.types import Transcript, Word + + # Real pattern from transcript 38d84d57: words with identical timestamps + transcript = Transcript( + words=[ + Word(text="speaking ", start=6.71, end=7.11, speaker=0), + Word(text="Speaking ", start=6.71, end=7.11, speaker=1), + Word(text="at ", start=7.11, end=7.27, speaker=0), + Word(text="at ", start=7.11, end=7.27, speaker=1), + Word(text="the ", start=7.27, end=7.43, speaker=0), + Word(text="the ", start=7.27, end=7.43, speaker=1), + Word(text="same ", start=7.43, end=7.59, speaker=0), + Word(text="same ", start=7.43, end=7.59, speaker=1), + Word(text="time.", start=7.59, end=8.0, speaker=0), + Word(text="time.", start=7.59, end=8.0, speaker=1), + ] + ) + + # Default: 10 segments (one per speaker change) + segments_default = transcript.as_segments(is_multitrack=False) + assert len(segments_default) == 10 + + # Multitrack: 2 segments (one per speaker sentence) + segments_multitrack = transcript.as_segments(is_multitrack=True) + assert len(segments_multitrack) == 2 + + # Both should have complete sentences + assert "speaking at the same time." in segments_multitrack[0].text + assert "Speaking at the same time." in segments_multitrack[1].text diff --git a/server/tests/test_transcript_formats.py b/server/tests/test_transcript_formats.py index 62e382fe..ea44636d 100644 --- a/server/tests/test_transcript_formats.py +++ b/server/tests/test_transcript_formats.py @@ -273,8 +273,17 @@ async def test_transcript_formats_with_multiple_speakers(): @pytest.mark.asyncio -async def test_transcript_formats_with_overlapping_speakers(): - """Test format conversion when multiple speakers speak at the same time (overlapping timestamps).""" +async def test_transcript_formats_with_overlapping_speakers_multitrack(): + """Test format conversion for multitrack recordings with truly interleaved words. + + Multitrack recordings have words from different speakers sorted by start time, + causing frequent speaker alternation. This tests the sentence-based segmentation + that groups each speaker's words into complete sentences. + """ + # Real multitrack data: words sorted by start time, speakers interleave + # Alice says: "Hello there." (0.0-1.0) + # Bob says: "I'm good." (0.5-1.5) + # When sorted by time, words interleave: Hello, I'm, there., good. topics = [ TranscriptTopic( id="1", @@ -282,11 +291,10 @@ async def test_transcript_formats_with_overlapping_speakers(): summary="Summary 1", timestamp=0.0, words=[ - Word(text="Hello", start=0.0, end=0.5, speaker=0), - Word(text=" there.", start=0.5, end=1.0, speaker=0), - # Speaker 1 overlaps with speaker 0 at 0.5-1.0 - Word(text="I'm", start=0.5, end=1.0, speaker=1), - Word(text=" good.", start=1.0, end=1.5, speaker=1), + Word(text="Hello ", start=0.0, end=0.5, speaker=0), + Word(text="I'm ", start=0.5, end=0.8, speaker=1), + Word(text="there.", start=0.5, end=1.0, speaker=0), + Word(text="good.", start=1.0, end=1.5, speaker=1), ], ), ] @@ -296,20 +304,9 @@ async def test_transcript_formats_with_overlapping_speakers(): TranscriptParticipant(id="2", speaker=1, name="Bob"), ] - text_result = transcript_to_text(topics, participants) - lines = text_result.split("\n") - assert len(lines) >= 2 - assert any("Alice:" in line for line in lines) - assert any("Bob:" in line for line in lines) - - timestamped_result = transcript_to_text_timestamped(topics, participants) - timestamped_lines = timestamped_result.split("\n") - assert len(timestamped_lines) >= 2 - assert any("Alice:" in line for line in timestamped_lines) - assert any("Bob:" in line for line in timestamped_lines) - assert any("[00:00]" in line for line in timestamped_lines) - - webvtt_result = topics_to_webvtt_named(topics, participants) + # With is_multitrack=True, should produce 2 segments (one per speaker sentence) + # not 4 segments (one per speaker change) + webvtt_result = topics_to_webvtt_named(topics, participants, is_multitrack=True) expected_webvtt = """WEBVTT 00:00:00.000 --> 00:00:01.000 @@ -320,23 +317,26 @@ async def test_transcript_formats_with_overlapping_speakers(): """ assert webvtt_result == expected_webvtt - segments = transcript_to_json_segments(topics, participants) - assert len(segments) >= 2 - speakers = {seg.speaker for seg in segments} - assert 0 in speakers and 1 in speakers + text_result = transcript_to_text(topics, participants, is_multitrack=True) + lines = text_result.split("\n") + assert len(lines) == 2 + assert "Alice: Hello there." in lines[0] + assert "Bob: I'm good." in lines[1] - alice_seg = next(seg for seg in segments if seg.speaker == 0) - bob_seg = next(seg for seg in segments if seg.speaker == 1) + timestamped_result = transcript_to_text_timestamped( + topics, participants, is_multitrack=True + ) + timestamped_lines = timestamped_result.split("\n") + assert len(timestamped_lines) == 2 + assert "[00:00] Alice: Hello there." in timestamped_lines[0] + assert "[00:00] Bob: I'm good." in timestamped_lines[1] - # Verify timestamps overlap: Alice (0.0-1.0) and Bob (0.5-1.5) overlap at 0.5-1.0 - assert alice_seg.start < bob_seg.end, "Alice segment should start before Bob ends" - assert bob_seg.start < alice_seg.end, "Bob segment should start before Alice ends" - - overlap_start = max(alice_seg.start, bob_seg.start) - overlap_end = min(alice_seg.end, bob_seg.end) - assert ( - overlap_start < overlap_end - ), f"Segments should overlap between {overlap_start} and {overlap_end}" + segments = transcript_to_json_segments(topics, participants, is_multitrack=True) + assert len(segments) == 2 + assert segments[0].speaker_name == "Alice" + assert segments[0].text == "Hello there." + assert segments[1].speaker_name == "Bob" + assert segments[1].text == "I'm good." @pytest.mark.asyncio @@ -573,3 +573,207 @@ async def test_api_transcript_format_default_is_text(client): assert data["transcript_format"] == "text" assert "transcript" in data + + +@pytest.mark.asyncio +async def test_api_topics_endpoint_multitrack_segmentation(client): + """Test GET /transcripts/{id}/topics uses sentence-based segmentation for multitrack. + + This tests the fix for TASKS2.md - ensuring /topics endpoints correctly detect + multitrack recordings and use sentence-based segmentation instead of fragmenting + on every speaker change. + """ + from datetime import datetime, timezone + + from reflector.db.recordings import Recording, recordings_controller + from reflector.db.transcripts import ( + TranscriptParticipant, + TranscriptTopic, + transcripts_controller, + ) + from reflector.processors.types import Word + + # Create a multitrack recording (has track_keys) + recording = Recording( + bucket_name="test-bucket", + object_key="test-key", + recorded_at=datetime.now(timezone.utc), + track_keys=["track1.webm", "track2.webm"], # This makes it multitrack + ) + await recordings_controller.create(recording) + + # Create transcript linked to the recording + transcript = await transcripts_controller.add( + name="Multitrack Test", + source_kind="file", + recording_id=recording.id, + ) + + await transcripts_controller.update( + transcript, + { + "participants": [ + TranscriptParticipant(id="1", speaker=0, name="Alice").model_dump(), + TranscriptParticipant(id="2", speaker=1, name="Bob").model_dump(), + ] + }, + ) + + # Add interleaved words (as they appear in real multitrack data) + await transcripts_controller.upsert_topic( + transcript, + TranscriptTopic( + title="Topic 1", + summary="Summary 1", + timestamp=0, + words=[ + Word(text="Hello ", start=0.0, end=0.5, speaker=0), + Word(text="I'm ", start=0.5, end=0.8, speaker=1), + Word(text="there.", start=0.5, end=1.0, speaker=0), + Word(text="good.", start=1.0, end=1.5, speaker=1), + ], + ), + ) + + # Test /topics endpoint + response = await client.get(f"/transcripts/{transcript.id}/topics") + assert response.status_code == 200 + data = response.json() + + assert len(data) == 1 + topic = data[0] + + # Key assertion: multitrack should produce 2 segments (one per speaker sentence) + # Not 4 segments (one per speaker change) + assert len(topic["segments"]) == 2 + + # Check content + segment_texts = [s["text"] for s in topic["segments"]] + assert "Hello there." in segment_texts + assert "I'm good." in segment_texts + + +@pytest.mark.asyncio +async def test_api_topics_endpoint_non_multitrack_segmentation(client): + """Test GET /transcripts/{id}/topics uses default segmentation for non-multitrack. + + Ensures backward compatibility - transcripts without multitrack recordings + should continue using the default speaker-change-based segmentation. + """ + from reflector.db.transcripts import ( + TranscriptParticipant, + TranscriptTopic, + transcripts_controller, + ) + from reflector.processors.types import Word + + # Create transcript WITHOUT recording (defaulted as not multitrack) TODO better heuristic + response = await client.post("/transcripts", json={"name": "Test transcript"}) + assert response.status_code == 200 + tid = response.json()["id"] + + transcript = await transcripts_controller.get_by_id(tid) + + await transcripts_controller.update( + transcript, + { + "participants": [ + TranscriptParticipant(id="1", speaker=0, name="Alice").model_dump(), + TranscriptParticipant(id="2", speaker=1, name="Bob").model_dump(), + ] + }, + ) + + # Add interleaved words + await transcripts_controller.upsert_topic( + transcript, + TranscriptTopic( + title="Topic 1", + summary="Summary 1", + timestamp=0, + words=[ + Word(text="Hello ", start=0.0, end=0.5, speaker=0), + Word(text="I'm ", start=0.5, end=0.8, speaker=1), + Word(text="there.", start=0.5, end=1.0, speaker=0), + Word(text="good.", start=1.0, end=1.5, speaker=1), + ], + ), + ) + + # Test /topics endpoint + response = await client.get(f"/transcripts/{tid}/topics") + assert response.status_code == 200 + data = response.json() + + assert len(data) == 1 + topic = data[0] + + # Non-multitrack: should produce 4 segments (one per speaker change) + assert len(topic["segments"]) == 4 + + +@pytest.mark.asyncio +async def test_api_topics_with_words_endpoint_multitrack(client): + """Test GET /transcripts/{id}/topics/with-words uses multitrack segmentation.""" + from datetime import datetime, timezone + + from reflector.db.recordings import Recording, recordings_controller + from reflector.db.transcripts import ( + TranscriptParticipant, + TranscriptTopic, + transcripts_controller, + ) + from reflector.processors.types import Word + + # Create multitrack recording + recording = Recording( + bucket_name="test-bucket", + object_key="test-key-2", + recorded_at=datetime.now(timezone.utc), + track_keys=["track1.webm", "track2.webm"], + ) + await recordings_controller.create(recording) + + transcript = await transcripts_controller.add( + name="Multitrack Test 2", + source_kind="file", + recording_id=recording.id, + ) + + await transcripts_controller.update( + transcript, + { + "participants": [ + TranscriptParticipant(id="1", speaker=0, name="Alice").model_dump(), + TranscriptParticipant(id="2", speaker=1, name="Bob").model_dump(), + ] + }, + ) + + await transcripts_controller.upsert_topic( + transcript, + TranscriptTopic( + title="Topic 1", + summary="Summary 1", + timestamp=0, + words=[ + Word(text="Hello ", start=0.0, end=0.5, speaker=0), + Word(text="I'm ", start=0.5, end=0.8, speaker=1), + Word(text="there.", start=0.5, end=1.0, speaker=0), + Word(text="good.", start=1.0, end=1.5, speaker=1), + ], + ), + ) + + response = await client.get(f"/transcripts/{transcript.id}/topics/with-words") + assert response.status_code == 200 + data = response.json() + + assert len(data) == 1 + topic = data[0] + + # Should have 2 segments (multitrack sentence-based) + assert len(topic["segments"]) == 2 + # Should also have words field + assert "words" in topic + assert len(topic["words"]) == 4 From 692895c8590d5b75afb3521964e5301591e84eb1 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Wed, 26 Nov 2025 15:53:27 -0600 Subject: [PATCH 13/26] chore(main): release 0.22.0 (#748) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 923fe485..04167e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.22.0](https://github.com/Monadical-SAS/reflector/compare/v0.21.0...v0.22.0) (2025-11-26) + + +### Features + +* Multitrack segmentation ([#747](https://github.com/Monadical-SAS/reflector/issues/747)) ([d63040e](https://github.com/Monadical-SAS/reflector/commit/d63040e2fdc07e7b272e85a39eb2411cd6a14798)) + ## [0.21.0](https://github.com/Monadical-SAS/reflector/compare/v0.20.0...v0.21.0) (2025-11-26) From 7f0b728991c1b9f9aae702c96297eae63b561ef5 Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Thu, 27 Nov 2025 16:53:26 +0100 Subject: [PATCH 14/26] fix: participants update from daily (#749) * Fix participants update from daily * Use track keys from params --- server/reflector/dailyco_api/responses.py | 2 +- .../pipelines/main_multitrack_pipeline.py | 95 +++++++++++++++++++ server/reflector/worker/process.py | 85 +---------------- 3 files changed, 98 insertions(+), 84 deletions(-) diff --git a/server/reflector/dailyco_api/responses.py b/server/reflector/dailyco_api/responses.py index 3dc18815..279682ae 100644 --- a/server/reflector/dailyco_api/responses.py +++ b/server/reflector/dailyco_api/responses.py @@ -68,7 +68,7 @@ class MeetingParticipant(BaseModel): Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants """ - user_id: NonEmptyString = Field(description="User identifier") + user_id: NonEmptyString | None = Field(None, description="User identifier") participant_id: NonEmptyString = Field(description="Participant session identifier") user_name: NonEmptyString | None = Field(None, description="User display name") join_time: int = Field(description="Join timestamp (Unix epoch seconds)") diff --git a/server/reflector/pipelines/main_multitrack_pipeline.py b/server/reflector/pipelines/main_multitrack_pipeline.py index f91c8250..d202206c 100644 --- a/server/reflector/pipelines/main_multitrack_pipeline.py +++ b/server/reflector/pipelines/main_multitrack_pipeline.py @@ -9,7 +9,10 @@ from av.audio.resampler import AudioResampler from celery import chain, shared_task from reflector.asynctask import asynctask +from reflector.dailyco_api import MeetingParticipantsResponse from reflector.db.transcripts import ( + Transcript, + TranscriptParticipant, TranscriptStatus, TranscriptWaveform, transcripts_controller, @@ -29,7 +32,12 @@ from reflector.processors.audio_waveform_processor import AudioWaveformProcessor from reflector.processors.types import TitleSummary from reflector.processors.types import Transcript as TranscriptType from reflector.storage import Storage, get_transcripts_storage +from reflector.utils.daily import ( + filter_cam_audio_tracks, + parse_daily_recording_filename, +) from reflector.utils.string import NonEmptyString +from reflector.video_platforms.factory import create_platform_client # Audio encoding constants OPUS_STANDARD_SAMPLE_RATE = 48000 @@ -494,6 +502,90 @@ class PipelineMainMultitrack(PipelineMainBase): transcript=transcript, event="WAVEFORM", data=waveform ) + async def update_participants_from_daily( + self, transcript: Transcript, track_keys: list[str] + ) -> None: + """Update transcript participants with user_id and names from Daily.co API.""" + if not transcript.recording_id: + return + + try: + async with create_platform_client("daily") as daily_client: + id_to_name = {} + id_to_user_id = {} + + try: + rec_details = await daily_client.get_recording( + transcript.recording_id + ) + mtg_session_id = rec_details.mtgSessionId + if mtg_session_id: + try: + payload: MeetingParticipantsResponse = ( + await daily_client.get_meeting_participants( + mtg_session_id + ) + ) + for p in payload.data: + pid = p.participant_id + name = p.user_name + user_id = p.user_id + if name: + id_to_name[pid] = name + if user_id: + id_to_user_id[pid] = user_id + except Exception as e: + self.logger.warning( + "Failed to fetch Daily meeting participants", + error=str(e), + mtg_session_id=mtg_session_id, + exc_info=True, + ) + else: + self.logger.warning( + "No mtgSessionId found for recording; participant names may be generic", + recording_id=transcript.recording_id, + ) + except Exception as e: + self.logger.warning( + "Failed to fetch Daily recording details", + error=str(e), + recording_id=transcript.recording_id, + exc_info=True, + ) + return + + 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: + self.logger.error( + "Failed to parse Daily recording filename", + error=str(e), + key=key, + exc_info=True, + ) + 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 + ) + + except Exception as e: + self.logger.warning( + "Failed to map participant names", error=str(e), exc_info=True + ) + async def process(self, bucket_name: str, track_keys: list[str]): transcript = await self.get_transcript() async with self.transaction(): @@ -502,9 +594,12 @@ class PipelineMainMultitrack(PipelineMainBase): { "events": [], "topics": [], + "participants": [], }, ) + await self.update_participants_from_daily(transcript, track_keys) + source_storage = get_transcripts_storage() transcript_storage = source_storage diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py index adf73d15..21e73723 100644 --- a/server/reflector/worker/process.py +++ b/server/reflector/worker/process.py @@ -12,7 +12,7 @@ from celery import shared_task from celery.utils.log import get_task_logger from pydantic import ValidationError -from reflector.dailyco_api import MeetingParticipantsResponse, RecordingResponse +from reflector.dailyco_api import RecordingResponse from reflector.db.daily_participant_sessions import ( DailyParticipantSession, daily_participant_sessions_controller, @@ -22,7 +22,6 @@ from reflector.db.recordings import Recording, recordings_controller from reflector.db.rooms import rooms_controller from reflector.db.transcripts import ( SourceKind, - TranscriptParticipant, transcripts_controller, ) from reflector.pipelines.main_file_pipeline import task_pipeline_file_process @@ -40,7 +39,6 @@ from reflector.utils.daily import ( DailyRoomName, extract_base_room_name, filter_cam_audio_tracks, - parse_daily_recording_filename, recording_lock_key, ) from reflector.video_platforms.factory import create_platform_client @@ -275,15 +273,7 @@ async def _process_multitrack_recording_inner( # else: Recording already exists; metadata set at creation time transcript = await transcripts_controller.get_by_recording_id(recording.id) - if transcript: - await transcripts_controller.update( - transcript, - { - "topics": [], - "participants": [], - }, - ) - else: + if not transcript: transcript = await transcripts_controller.add( "", source_kind=SourceKind.ROOM, @@ -296,77 +286,6 @@ async def _process_multitrack_recording_inner( room_id=room.id, ) - try: - async with create_platform_client("daily") as daily_client: - id_to_name = {} - id_to_user_id = {} - - try: - rec_details = await daily_client.get_recording(recording_id) - mtg_session_id = rec_details.mtgSessionId - if mtg_session_id: - try: - payload: MeetingParticipantsResponse = ( - await daily_client.get_meeting_participants(mtg_session_id) - ) - for p in payload.data: - pid = p.participant_id - assert ( - pid is not None - ), "panic! participant id cannot be None" - name = p.user_name - user_id = p.user_id - if name: - id_to_name[pid] = name - if user_id: - id_to_user_id[pid] = user_id - except Exception as e: - logger.warning( - "Failed to fetch Daily meeting participants", - error=str(e), - mtg_session_id=mtg_session_id, - exc_info=True, - ) - else: - logger.warning( - "No mtgSessionId found for recording; participant names may be generic", - recording_id=recording_id, - ) - except Exception as e: - logger.warning( - "Failed to fetch Daily recording details", - error=str(e), - recording_id=recording_id, - exc_info=True, - ) - - 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, - exc_info=True, - ) - 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) - - except Exception as e: - logger.warning("Failed to map participant names", error=str(e), exc_info=True) - task_pipeline_multitrack_process.delay( transcript_id=transcript.id, bucket_name=bucket_name, From a2bb6a27d6ac809ff79e0ed0ca01be441e67893f Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 27 Nov 2025 09:55:08 -0600 Subject: [PATCH 15/26] chore(main): release 0.22.1 (#750) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04167e5a..aca911f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.22.1](https://github.com/Monadical-SAS/reflector/compare/v0.22.0...v0.22.1) (2025-11-27) + + +### Bug Fixes + +* participants update from daily ([#749](https://github.com/Monadical-SAS/reflector/issues/749)) ([7f0b728](https://github.com/Monadical-SAS/reflector/commit/7f0b728991c1b9f9aae702c96297eae63b561ef5)) + ## [0.22.0](https://github.com/Monadical-SAS/reflector/compare/v0.21.0...v0.22.0) (2025-11-26) From fe47c46489c5aa0cc538109f7559cc9accb35c01 Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Thu, 27 Nov 2025 18:31:03 -0500 Subject: [PATCH 16/26] fix: daily auto refresh fix (#755) * daily auto refresh fix * Update www/app/lib/AuthProvider.tsx Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com> * Update www/app/[roomName]/components/DailyRoom.tsx Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com> * fix bot lint --------- Co-authored-by: Igor Loskutov Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com> --- www/app/[roomName]/components/DailyRoom.tsx | 28 +++++++++++++-------- www/app/lib/AuthProvider.tsx | 12 ++++++++- www/app/lib/authBackend.ts | 2 +- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 2faedb90..41105be3 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -11,6 +11,7 @@ import { recordingTypeRequiresConsent, } from "../../lib/consent"; import { useRoomJoinMeeting } from "../../lib/apiHooks"; +import { assertExists } from "../../lib/utils"; type Meeting = components["schemas"]["Meeting"]; @@ -22,16 +23,16 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { const router = useRouter(); const params = useParams(); const auth = useAuth(); - const status = auth.status; + const authStatus = auth.status; + const authLastUserId = auth.lastUserId; const containerRef = useRef(null); const joinMutation = useRoomJoinMeeting(); const [joinedMeeting, setJoinedMeeting] = useState(null); const roomName = params?.roomName as string; - // Always call /join to get a fresh token with user_id useEffect(() => { - if (status === "loading" || !meeting?.id || !roomName) return; + if (authLastUserId === null || !meeting?.id || !roomName) return; const join = async () => { try { @@ -50,18 +51,16 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { }; join(); - }, [meeting?.id, roomName, status]); + }, [meeting?.id, roomName, authLastUserId]); const roomUrl = joinedMeeting?.host_room_url || joinedMeeting?.room_url; - const isLoading = - status === "loading" || joinMutation.isPending || !joinedMeeting; const handleLeave = useCallback(() => { router.push("/browse"); }, [router]); useEffect(() => { - if (isLoading || !roomUrl || !containerRef.current) return; + if (!authLastUserId || !roomUrl || !containerRef.current) return; let frame: DailyCall | null = null; let destroyed = false; @@ -90,9 +89,14 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { frame.on("left-meeting", handleLeave); + // TODO this method must not ignore no-recording option + // TODO this method is here to make dev environments work in some cases (not examined which) frame.on("joined-meeting", async () => { try { - await frame.startRecording({ type: "raw-tracks" }); + assertExists( + frame, + "frame object got lost somewhere after frame.on was called", + ).startRecording({ type: "raw-tracks" }); } catch (error) { console.error("Failed to start recording:", error); } @@ -104,7 +108,9 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { } }; - createAndJoin(); + createAndJoin().catch((error) => { + console.error("Failed to create and join meeting:", error); + }); return () => { destroyed = true; @@ -114,9 +120,9 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { }); } }; - }, [roomUrl, isLoading, handleLeave]); + }, [roomUrl, authLastUserId, handleLeave]); - if (isLoading) { + if (!authLastUserId) { return (
diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx index e1eabf99..6a0b9f82 100644 --- a/www/app/lib/AuthProvider.tsx +++ b/www/app/lib/AuthProvider.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useRef } from "react"; import { useSession as useNextAuthSession } from "next-auth/react"; import { signOut, signIn } from "next-auth/react"; import { configureApiAuth } from "./apiClient"; @@ -25,6 +25,8 @@ type AuthContextType = ( update: () => Promise; signIn: typeof signIn; signOut: typeof signOut; + // TODO probably rename isLoading to isReloading and make THIS field "isLoading" + lastUserId: CustomSession["user"]["id"] | null; }; const AuthContext = createContext(undefined); @@ -41,10 +43,13 @@ const noopAuthContext: AuthContextType = { signOut: async () => { throw new Error("signOut not supposed to be called"); }, + lastUserId: null, }; export function AuthProvider({ children }: { children: React.ReactNode }) { const { data: session, status, update } = useNextAuthSession(); + // referential comparison done in component, must be primitive /or cached + const lastUserId = useRef(null); const contextValue: AuthContextType = isAuthEnabled ? { @@ -78,6 +83,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { status: "unauthenticated" as const, }; } else if (customSession?.accessToken) { + // updates anyways with updated properties below + // warning! execution order conscience, must be ran before reading lastUserId.current below + lastUserId.current = customSession.user.id; return { status, accessToken: customSession.accessToken, @@ -103,6 +111,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { update, signIn, signOut, + // for optimistic cases when we assume "loading" doesn't immediately invalidate the user + lastUserId: lastUserId.current, } : noopAuthContext; diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts index c28ee224..e439e1b0 100644 --- a/www/app/lib/authBackend.ts +++ b/www/app/lib/authBackend.ts @@ -148,7 +148,7 @@ export const authOptions = (): AuthOptions => }, async session({ session, token }) { const extendedToken = token as JWTWithAccessToken; - + console.log("extendedToken", extendedToken); const userId = await getUserId(extendedToken.accessToken); return { From a8983b4e7e15433d8a49cb7254ea888080cdace9 Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Fri, 28 Nov 2025 14:52:59 -0500 Subject: [PATCH 17/26] daily auth hotfix (#757) Co-authored-by: Igor Loskutov --- www/app/[roomName]/components/DailyRoom.tsx | 8 ++++---- www/app/lib/AuthProvider.tsx | 13 ++++++++++--- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 41105be3..8c0302e7 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -23,7 +23,6 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { const router = useRouter(); const params = useParams(); const auth = useAuth(); - const authStatus = auth.status; const authLastUserId = auth.lastUserId; const containerRef = useRef(null); const joinMutation = useRoomJoinMeeting(); @@ -32,7 +31,7 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { const roomName = params?.roomName as string; useEffect(() => { - if (authLastUserId === null || !meeting?.id || !roomName) return; + if (authLastUserId === undefined || !meeting?.id || !roomName) return; const join = async () => { try { @@ -60,7 +59,8 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { }, [router]); useEffect(() => { - if (!authLastUserId || !roomUrl || !containerRef.current) return; + if (authLastUserId === undefined || !roomUrl || !containerRef.current) + return; let frame: DailyCall | null = null; let destroyed = false; @@ -122,7 +122,7 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { }; }, [roomUrl, authLastUserId, handleLeave]); - if (!authLastUserId) { + if (authLastUserId === undefined) { return (
diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx index 6a0b9f82..1c281a37 100644 --- a/www/app/lib/AuthProvider.tsx +++ b/www/app/lib/AuthProvider.tsx @@ -26,7 +26,8 @@ type AuthContextType = ( signIn: typeof signIn; signOut: typeof signOut; // TODO probably rename isLoading to isReloading and make THIS field "isLoading" - lastUserId: CustomSession["user"]["id"] | null; + // undefined is "not known", null is "is certainly logged out" + lastUserId: CustomSession["user"]["id"] | null | undefined; }; const AuthContext = createContext(undefined); @@ -43,13 +44,15 @@ const noopAuthContext: AuthContextType = { signOut: async () => { throw new Error("signOut not supposed to be called"); }, - lastUserId: null, + lastUserId: undefined, }; export function AuthProvider({ children }: { children: React.ReactNode }) { const { data: session, status, update } = useNextAuthSession(); // referential comparison done in component, must be primitive /or cached - const lastUserId = useRef(null); + const lastUserId = useRef( + null, + ); const contextValue: AuthContextType = isAuthEnabled ? { @@ -78,6 +81,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { case "authenticated": { const customSession = assertCustomSession(session); if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) { + // warning: call order-dependent + lastUserId.current = null; // token had expired but next auth still returns "authenticated" so show user unauthenticated state return { status: "unauthenticated" as const, @@ -100,6 +105,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } } case "unauthenticated": { + // warning: call order-dependent + lastUserId.current = null; return { status: "unauthenticated" as const }; } default: { From b51b7aa9176c1a53ba57ad99f5e976c804a1e80c Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Mon, 1 Dec 2025 23:35:12 -0500 Subject: [PATCH 18/26] fix: Skip mixdown for multitrack (#760) * multitrack mixdown optimisation * skip mixdown for multitrack * skip mixdown for multitrack --------- Co-authored-by: Igor Loskutov --- .../pipelines/main_multitrack_pipeline.py | 79 +++++++++++-------- server/reflector/settings.py | 8 ++ .../(app)/transcripts/[transcriptId]/page.tsx | 16 ++-- 3 files changed, 60 insertions(+), 43 deletions(-) diff --git a/server/reflector/pipelines/main_multitrack_pipeline.py b/server/reflector/pipelines/main_multitrack_pipeline.py index d202206c..2b23c7b6 100644 --- a/server/reflector/pipelines/main_multitrack_pipeline.py +++ b/server/reflector/pipelines/main_multitrack_pipeline.py @@ -31,6 +31,7 @@ from reflector.processors import AudioFileWriterProcessor from reflector.processors.audio_waveform_processor import AudioWaveformProcessor from reflector.processors.types import TitleSummary from reflector.processors.types import Transcript as TranscriptType +from reflector.settings import settings from reflector.storage import Storage, get_transcripts_storage from reflector.utils.daily import ( filter_cam_audio_tracks, @@ -631,43 +632,55 @@ class PipelineMainMultitrack(PipelineMainBase): transcript.data_path.mkdir(parents=True, exist_ok=True) - mp3_writer = AudioFileWriterProcessor( - path=str(transcript.audio_mp3_filename), - on_duration=self.on_duration, - ) - await self.mixdown_tracks(padded_track_urls, mp3_writer, offsets_seconds=None) - await mp3_writer.flush() + if settings.SKIP_MIXDOWN: + self.logger.warning( + "SKIP_MIXDOWN enabled: Skipping mixdown and waveform generation. " + "UI will have no audio playback or waveform.", + num_tracks=len(padded_track_urls), + transcript_id=transcript.id, + ) + else: + mp3_writer = AudioFileWriterProcessor( + path=str(transcript.audio_mp3_filename), + on_duration=self.on_duration, + ) + await self.mixdown_tracks( + padded_track_urls, mp3_writer, offsets_seconds=None + ) + await mp3_writer.flush() - if not transcript.audio_mp3_filename.exists(): - raise Exception( - "Mixdown failed - no MP3 file generated. Cannot proceed without playable audio." + if not transcript.audio_mp3_filename.exists(): + raise Exception( + "Mixdown failed - no MP3 file generated. Cannot proceed without playable audio." + ) + + storage_path = f"{transcript.id}/audio.mp3" + # Use file handle streaming to avoid loading entire MP3 into memory + mp3_size = transcript.audio_mp3_filename.stat().st_size + with open(transcript.audio_mp3_filename, "rb") as mp3_file: + await transcript_storage.put_file(storage_path, mp3_file) + mp3_url = await transcript_storage.get_file_url(storage_path) + + await transcripts_controller.update( + transcript, {"audio_location": "storage"} ) - storage_path = f"{transcript.id}/audio.mp3" - # Use file handle streaming to avoid loading entire MP3 into memory - mp3_size = transcript.audio_mp3_filename.stat().st_size - with open(transcript.audio_mp3_filename, "rb") as mp3_file: - await transcript_storage.put_file(storage_path, mp3_file) - mp3_url = await transcript_storage.get_file_url(storage_path) + self.logger.info( + f"Uploaded mixed audio to storage", + storage_path=storage_path, + size=mp3_size, + url=mp3_url, + ) - await transcripts_controller.update(transcript, {"audio_location": "storage"}) - - self.logger.info( - f"Uploaded mixed audio to storage", - storage_path=storage_path, - size=mp3_size, - url=mp3_url, - ) - - self.logger.info("Generating waveform from mixed audio") - waveform_processor = AudioWaveformProcessor( - audio_path=transcript.audio_mp3_filename, - waveform_path=transcript.audio_waveform_filename, - on_waveform=self.on_waveform, - ) - waveform_processor.set_pipeline(self.empty_pipeline) - await waveform_processor.flush() - self.logger.info("Waveform generated successfully") + self.logger.info("Generating waveform from mixed audio") + waveform_processor = AudioWaveformProcessor( + audio_path=transcript.audio_mp3_filename, + waveform_path=transcript.audio_waveform_filename, + on_waveform=self.on_waveform, + ) + waveform_processor.set_pipeline(self.empty_pipeline) + await waveform_processor.flush() + self.logger.info("Waveform generated successfully") speaker_transcripts: list[TranscriptType] = [] for idx, padded_url in enumerate(padded_track_urls): diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 1ec46d94..338e1da9 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -138,6 +138,14 @@ class Settings(BaseSettings): DAILY_WEBHOOK_UUID: str | None = ( None # Webhook UUID for this environment. Not used by production code ) + + # Multitrack processing + # SKIP_MIXDOWN: When True, skips audio mixdown and waveform generation. + # Transcription still works using individual tracks. Useful for: + # - Diagnosing OOM issues in mixdown + # - Fast processing when audio playback is not needed + # Note: UI will have no audio playback or waveform when enabled. + SKIP_MIXDOWN: bool = True # Platform Configuration DEFAULT_VIDEO_PLATFORM: Platform = WHEREBY_PLATFORM diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx index 1e020f1c..ead2d259 100644 --- a/www/app/(app)/transcripts/[transcriptId]/page.tsx +++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx @@ -117,15 +117,6 @@ export default function TranscriptDetails(details: TranscriptDetails) { return ; } - if (mp3.error) { - return ( - - ); - } - return ( <> ) : !mp3.loading && (waveform.error || mp3.error) ? ( - Error loading this recording + + Error loading{" "} + {[waveform.error && "waveform", mp3.error && "mp3"] + .filter(Boolean) + .join(" and ")} + ) : ( From dabf7251dbd0522f5b3ffe94596c9a7836284085 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Mon, 1 Dec 2025 22:39:32 -0600 Subject: [PATCH 19/26] chore(main): release 0.22.2 (#756) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aca911f6..279b7210 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.22.2](https://github.com/Monadical-SAS/reflector/compare/v0.22.1...v0.22.2) (2025-12-02) + + +### Bug Fixes + +* daily auto refresh fix ([#755](https://github.com/Monadical-SAS/reflector/issues/755)) ([fe47c46](https://github.com/Monadical-SAS/reflector/commit/fe47c46489c5aa0cc538109f7559cc9accb35c01)) +* Skip mixdown for multitrack ([#760](https://github.com/Monadical-SAS/reflector/issues/760)) ([b51b7aa](https://github.com/Monadical-SAS/reflector/commit/b51b7aa9176c1a53ba57ad99f5e976c804a1e80c)) + ## [0.22.1](https://github.com/Monadical-SAS/reflector/compare/v0.22.0...v0.22.1) (2025-11-27) From 28f87c09dc459846873d0dde65b03e3d7b2b9399 Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Tue, 2 Dec 2025 09:06:36 +0100 Subject: [PATCH 20/26] fix: align daily room settings (#759) * Switch platform ui * Update room settings based on platform * Add local and none recording options to daily * Don't create tokens for unauthentikated users * Enable knocking for private rooms * Create new meeting on room settings change * Always use 2-200 option for daily * Show recording start trigger for daily * Fix broken test --- server/reflector/dailyco_api/requests.py | 4 + server/reflector/video_platforms/daily.py | 22 ++-- server/reflector/views/rooms.py | 31 +++-- www/app/(app)/rooms/page.tsx | 128 +++++++++++++++++--- www/app/[roomName]/components/DailyRoom.tsx | 13 +- 5 files changed, 158 insertions(+), 40 deletions(-) diff --git a/server/reflector/dailyco_api/requests.py b/server/reflector/dailyco_api/requests.py index e943b90f..54b25697 100644 --- a/server/reflector/dailyco_api/requests.py +++ b/server/reflector/dailyco_api/requests.py @@ -40,6 +40,10 @@ class RoomProperties(BaseModel): ) enable_chat: bool = Field(default=True, description="Enable in-meeting chat") enable_screenshare: bool = Field(default=True, description="Enable screen sharing") + enable_knocking: bool = Field( + default=False, + description="Enable knocking for private rooms (allows participants to request access)", + ) start_video_off: bool = Field( default=False, description="Start with video off for all participants" ) diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py index f7782ca9..7695b745 100644 --- a/server/reflector/video_platforms/daily.py +++ b/server/reflector/video_platforms/daily.py @@ -31,6 +31,7 @@ class DailyClient(VideoPlatformClient): PLATFORM_NAME: Platform = "daily" TIMESTAMP_FORMAT = "%Y%m%d%H%M%S" RECORDING_NONE: RecordingType = "none" + RECORDING_LOCAL: RecordingType = "local" RECORDING_CLOUD: RecordingType = "cloud" def __init__(self, config: VideoPlatformConfig): @@ -54,19 +55,23 @@ class DailyClient(VideoPlatformClient): timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT) room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{timestamp}" + enable_recording = None + if room.recording_type == self.RECORDING_LOCAL: + enable_recording = "local" + elif room.recording_type == self.RECORDING_CLOUD: + enable_recording = "raw-tracks" + properties = RoomProperties( - enable_recording="raw-tracks" - if room.recording_type != self.RECORDING_NONE - else False, + enable_recording=enable_recording, enable_chat=True, enable_screenshare=True, + enable_knocking=room.is_locked, start_video_off=False, start_audio_off=False, exp=int(end_date.timestamp()), ) - # Only configure recordings_bucket if recording is enabled - if room.recording_type != self.RECORDING_NONE: + if room.recording_type == self.RECORDING_CLOUD: daily_storage = get_dailyco_storage() assert daily_storage.bucket_name, "S3 bucket must be configured" properties.recordings_bucket = RecordingsBucketConfig( @@ -172,15 +177,16 @@ class DailyClient(VideoPlatformClient): async def create_meeting_token( self, room_name: DailyRoomName, - enable_recording: bool, + start_cloud_recording: bool, + enable_recording_ui: bool, user_id: NonEmptyString | None = None, is_owner: bool = False, ) -> NonEmptyString: properties = MeetingTokenProperties( room_name=room_name, user_id=user_id, - start_cloud_recording=enable_recording, - enable_recording_ui=False, + start_cloud_recording=start_cloud_recording, + enable_recording_ui=enable_recording_ui, is_owner=is_owner, ) request = CreateMeetingTokenRequest(properties=properties) diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index da5db1e8..5b218cb4 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -89,7 +89,7 @@ class CreateRoom(BaseModel): ics_url: Optional[str] = None ics_fetch_interval: int = 300 ics_enabled: bool = False - platform: Optional[Platform] = None + platform: Platform class UpdateRoom(BaseModel): @@ -248,7 +248,7 @@ async def rooms_create( ics_url=room.ics_url, ics_fetch_interval=room.ics_fetch_interval, ics_enabled=room.ics_enabled, - platform=room.platform or settings.DEFAULT_VIDEO_PLATFORM, + platform=room.platform, ) @@ -310,6 +310,22 @@ async def rooms_create_meeting( room=room, current_time=current_time ) + if meeting is not None: + settings_match = ( + meeting.is_locked == room.is_locked + and meeting.room_mode == room.room_mode + and meeting.recording_type == room.recording_type + and meeting.recording_trigger == room.recording_trigger + and meeting.platform == room.platform + ) + if not settings_match: + logger.info( + f"Room settings changed for {room_name}, creating new meeting", + room_id=room.id, + old_meeting_id=meeting.id, + ) + meeting = None + if meeting is None: end_date = current_time + timedelta(hours=8) @@ -549,21 +565,16 @@ async def rooms_join_meeting( if meeting.end_date <= current_time: raise HTTPException(status_code=400, detail="Meeting has ended") - if meeting.platform == "daily": + if meeting.platform == "daily" and user_id is not None: client = create_platform_client(meeting.platform) - enable_recording = room.recording_trigger != "none" token = await client.create_meeting_token( meeting.room_name, - enable_recording=enable_recording, + start_cloud_recording=meeting.recording_type == "cloud", + enable_recording_ui=meeting.recording_type == "local", user_id=user_id, is_owner=user_id == room.user_id, ) meeting = meeting.model_copy() meeting.room_url = add_query_param(meeting.room_url, "t", token) - if meeting.host_room_url: - meeting.host_room_url = add_query_param(meeting.host_room_url, "t", token) - - if user_id != room.user_id and meeting.platform == "whereby": - meeting.host_room_url = "" return meeting diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index a7a68d2f..198df774 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -67,6 +67,11 @@ const recordingTypeOptions: SelectOption[] = [ { label: "Cloud", value: "cloud" }, ]; +const platformOptions: SelectOption[] = [ + { label: "Whereby", value: "whereby" }, + { label: "Daily", value: "daily" }, +]; + const roomInitialState = { name: "", zulipAutoPost: false, @@ -82,6 +87,7 @@ const roomInitialState = { icsUrl: "", icsEnabled: false, icsFetchInterval: 5, + platform: "whereby", }; export default function RoomsList() { @@ -99,6 +105,11 @@ export default function RoomsList() { const recordingTypeCollection = createListCollection({ items: recordingTypeOptions, }); + + const platformCollection = createListCollection({ + items: platformOptions, + }); + const [roomInput, setRoomInput] = useState( null, ); @@ -143,15 +154,24 @@ export default function RoomsList() { zulipStream: detailedEditedRoom.zulip_stream, zulipTopic: detailedEditedRoom.zulip_topic, isLocked: detailedEditedRoom.is_locked, - roomMode: detailedEditedRoom.room_mode, + roomMode: + detailedEditedRoom.platform === "daily" + ? "group" + : detailedEditedRoom.room_mode, recordingType: detailedEditedRoom.recording_type, - recordingTrigger: detailedEditedRoom.recording_trigger, + recordingTrigger: + detailedEditedRoom.platform === "daily" + ? detailedEditedRoom.recording_type === "cloud" + ? "automatic-2nd-participant" + : "none" + : detailedEditedRoom.recording_trigger, isShared: detailedEditedRoom.is_shared, webhookUrl: detailedEditedRoom.webhook_url || "", webhookSecret: detailedEditedRoom.webhook_secret || "", icsUrl: detailedEditedRoom.ics_url || "", icsEnabled: detailedEditedRoom.ics_enabled || false, icsFetchInterval: detailedEditedRoom.ics_fetch_interval || 5, + platform: detailedEditedRoom.platform, } : null, [detailedEditedRoom], @@ -277,21 +297,32 @@ export default function RoomsList() { return; } + const platform: "whereby" | "daily" | null = + room.platform === "whereby" || room.platform === "daily" + ? room.platform + : null; + const roomData = { name: room.name, zulip_auto_post: room.zulipAutoPost, zulip_stream: room.zulipStream, zulip_topic: room.zulipTopic, is_locked: room.isLocked, - room_mode: room.roomMode, + room_mode: platform === "daily" ? "group" : room.roomMode, recording_type: room.recordingType, - recording_trigger: room.recordingTrigger, + recording_trigger: + platform === "daily" + ? room.recordingType === "cloud" + ? "automatic-2nd-participant" + : "none" + : room.recordingTrigger, is_shared: room.isShared, webhook_url: room.webhookUrl, webhook_secret: room.webhookSecret, ics_url: room.icsUrl, ics_enabled: room.icsEnabled, ics_fetch_interval: room.icsFetchInterval, + platform, }; if (isEditing) { @@ -339,15 +370,21 @@ export default function RoomsList() { zulipStream: roomData.zulip_stream, zulipTopic: roomData.zulip_topic, isLocked: roomData.is_locked, - roomMode: roomData.room_mode, + roomMode: roomData.platform === "daily" ? "group" : roomData.room_mode, // Daily always uses 2-200 recordingType: roomData.recording_type, - recordingTrigger: roomData.recording_trigger, + recordingTrigger: + roomData.platform === "daily" + ? roomData.recording_type === "cloud" + ? "automatic-2nd-participant" + : "none" + : roomData.recording_trigger, isShared: roomData.is_shared, webhookUrl: roomData.webhook_url || "", webhookSecret: roomData.webhook_secret || "", icsUrl: roomData.ics_url || "", icsEnabled: roomData.ics_enabled || false, icsFetchInterval: roomData.ics_fetch_interval || 5, + platform: roomData.platform, }); setEditRoomId(roomId); setIsEditing(true); @@ -482,6 +519,48 @@ export default function RoomsList() { )} + + Platform + { + const newPlatform = e.value[0] as "whereby" | "daily"; + const updates: Partial = { + platform: newPlatform, + }; + if (newPlatform === "daily") { + updates.roomMode = "group"; + updates.recordingTrigger = + room.recordingType === "cloud" + ? "automatic-2nd-participant" + : "none"; + } + setRoomInput({ ...room, ...updates }); + }} + collection={platformCollection} + > + + + + + + + + + + + + {platformOptions.map((option) => ( + + {option.label} + + + ))} + + + + + @@ -538,16 +618,26 @@ export default function RoomsList() { Recording type - setRoomInput({ - ...room, - recordingType: e.value[0], - recordingTrigger: - e.value[0] !== "cloud" + onValueChange={(e) => { + const newRecordingType = e.value[0]; + const updates: Partial = { + recordingType: newRecordingType, + }; + // For Daily: if cloud, use automatic; otherwise none + if (room.platform === "daily") { + updates.recordingTrigger = + newRecordingType === "cloud" + ? "automatic-2nd-participant" + : "none"; + } else { + // For Whereby: if not cloud, set to none + updates.recordingTrigger = + newRecordingType !== "cloud" ? "none" - : room.recordingTrigger, - }) - } + : room.recordingTrigger; + } + setRoomInput({ ...room, ...updates }); + }} collection={recordingTypeCollection} > @@ -572,7 +662,7 @@ export default function RoomsList() { - Cloud recording start trigger + Recording start trigger @@ -582,7 +672,11 @@ export default function RoomsList() { }) } collection={recordingTriggerCollection} - disabled={room.recordingType !== "cloud"} + disabled={ + room.recordingType !== "cloud" || + (room.platform === "daily" && + room.recordingType === "cloud") + } > diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx index 8c0302e7..45f2dad2 100644 --- a/www/app/[roomName]/components/DailyRoom.tsx +++ b/www/app/[roomName]/components/DailyRoom.tsx @@ -52,7 +52,7 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { join(); }, [meeting?.id, roomName, authLastUserId]); - const roomUrl = joinedMeeting?.host_room_url || joinedMeeting?.room_url; + const roomUrl = joinedMeeting?.room_url; const handleLeave = useCallback(() => { router.push("/browse"); @@ -89,14 +89,17 @@ export default function DailyRoom({ meeting }: DailyRoomProps) { frame.on("left-meeting", handleLeave); - // TODO this method must not ignore no-recording option - // TODO this method is here to make dev environments work in some cases (not examined which) frame.on("joined-meeting", async () => { try { - assertExists( + const frameInstance = assertExists( frame, "frame object got lost somewhere after frame.on was called", - ).startRecording({ type: "raw-tracks" }); + ); + + if (meeting.recording_type === "cloud") { + console.log("Starting cloud recording"); + await frameInstance.startRecording({ type: "raw-tracks" }); + } } catch (error) { console.error("Failed to start recording:", error); } From c8024484b321753c622f88faba6ab3270089f782 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 2 Dec 2025 02:08:22 -0600 Subject: [PATCH 21/26] chore(main): release 0.22.3 (#761) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 279b7210..f4b5db73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.22.3](https://github.com/Monadical-SAS/reflector/compare/v0.22.2...v0.22.3) (2025-12-02) + + +### Bug Fixes + +* align daily room settings ([#759](https://github.com/Monadical-SAS/reflector/issues/759)) ([28f87c0](https://github.com/Monadical-SAS/reflector/commit/28f87c09dc459846873d0dde65b03e3d7b2b9399)) + ## [0.22.2](https://github.com/Monadical-SAS/reflector/compare/v0.22.1...v0.22.2) (2025-12-02) From bd5df1ce2ebf35d7f3413b295e56937a9a28ef7b Mon Sep 17 00:00:00 2001 From: Igor Monadical Date: Tue, 2 Dec 2025 17:10:06 -0500 Subject: [PATCH 22/26] fix: Multitrack mixdown optimisation 2 (#764) * Revert "fix: Skip mixdown for multitrack (#760)" This reverts commit b51b7aa9176c1a53ba57ad99f5e976c804a1e80c. * multitrack mixdown optimisation * return the "good" ui part of "skip mixdown" --------- Co-authored-by: Igor Loskutov --- .../pipelines/main_multitrack_pipeline.py | 93 +++++++++---------- server/reflector/settings.py | 8 -- 2 files changed, 44 insertions(+), 57 deletions(-) diff --git a/server/reflector/pipelines/main_multitrack_pipeline.py b/server/reflector/pipelines/main_multitrack_pipeline.py index 2b23c7b6..579bfbd3 100644 --- a/server/reflector/pipelines/main_multitrack_pipeline.py +++ b/server/reflector/pipelines/main_multitrack_pipeline.py @@ -31,7 +31,6 @@ from reflector.processors import AudioFileWriterProcessor from reflector.processors.audio_waveform_processor import AudioWaveformProcessor from reflector.processors.types import TitleSummary from reflector.processors.types import Transcript as TranscriptType -from reflector.settings import settings from reflector.storage import Storage, get_transcripts_storage from reflector.utils.daily import ( filter_cam_audio_tracks, @@ -423,7 +422,15 @@ class PipelineMainMultitrack(PipelineMainBase): # Open all containers with cleanup guaranteed for i, url in enumerate(valid_track_urls): try: - c = av.open(url) + c = av.open( + url, + options={ + # it's trying to stream from s3 by default + "reconnect": "1", + "reconnect_streamed": "1", + "reconnect_delay_max": "5", + }, + ) containers.append(c) except Exception as e: self.logger.warning( @@ -452,6 +459,8 @@ class PipelineMainMultitrack(PipelineMainBase): frame = next(dec) except StopIteration: active[i] = False + # causes stream to move on / unclogs memory + inputs[i].push(None) continue if frame.sample_rate != target_sample_rate: @@ -471,8 +480,6 @@ class PipelineMainMultitrack(PipelineMainBase): mixed.time_base = Fraction(1, target_sample_rate) await writer.push(mixed) - for in_ctx in inputs: - in_ctx.push(None) while True: try: mixed = sink.pull() @@ -632,55 +639,43 @@ class PipelineMainMultitrack(PipelineMainBase): transcript.data_path.mkdir(parents=True, exist_ok=True) - if settings.SKIP_MIXDOWN: - self.logger.warning( - "SKIP_MIXDOWN enabled: Skipping mixdown and waveform generation. " - "UI will have no audio playback or waveform.", - num_tracks=len(padded_track_urls), - transcript_id=transcript.id, - ) - else: - mp3_writer = AudioFileWriterProcessor( - path=str(transcript.audio_mp3_filename), - on_duration=self.on_duration, - ) - await self.mixdown_tracks( - padded_track_urls, mp3_writer, offsets_seconds=None - ) - await mp3_writer.flush() + mp3_writer = AudioFileWriterProcessor( + path=str(transcript.audio_mp3_filename), + on_duration=self.on_duration, + ) + await self.mixdown_tracks(padded_track_urls, mp3_writer, offsets_seconds=None) + await mp3_writer.flush() - if not transcript.audio_mp3_filename.exists(): - raise Exception( - "Mixdown failed - no MP3 file generated. Cannot proceed without playable audio." - ) - - storage_path = f"{transcript.id}/audio.mp3" - # Use file handle streaming to avoid loading entire MP3 into memory - mp3_size = transcript.audio_mp3_filename.stat().st_size - with open(transcript.audio_mp3_filename, "rb") as mp3_file: - await transcript_storage.put_file(storage_path, mp3_file) - mp3_url = await transcript_storage.get_file_url(storage_path) - - await transcripts_controller.update( - transcript, {"audio_location": "storage"} + if not transcript.audio_mp3_filename.exists(): + raise Exception( + "Mixdown failed - no MP3 file generated. Cannot proceed without playable audio." ) - self.logger.info( - f"Uploaded mixed audio to storage", - storage_path=storage_path, - size=mp3_size, - url=mp3_url, - ) + storage_path = f"{transcript.id}/audio.mp3" + # Use file handle streaming to avoid loading entire MP3 into memory + mp3_size = transcript.audio_mp3_filename.stat().st_size + with open(transcript.audio_mp3_filename, "rb") as mp3_file: + await transcript_storage.put_file(storage_path, mp3_file) + mp3_url = await transcript_storage.get_file_url(storage_path) - self.logger.info("Generating waveform from mixed audio") - waveform_processor = AudioWaveformProcessor( - audio_path=transcript.audio_mp3_filename, - waveform_path=transcript.audio_waveform_filename, - on_waveform=self.on_waveform, - ) - waveform_processor.set_pipeline(self.empty_pipeline) - await waveform_processor.flush() - self.logger.info("Waveform generated successfully") + await transcripts_controller.update(transcript, {"audio_location": "storage"}) + + self.logger.info( + f"Uploaded mixed audio to storage", + storage_path=storage_path, + size=mp3_size, + url=mp3_url, + ) + + self.logger.info("Generating waveform from mixed audio") + waveform_processor = AudioWaveformProcessor( + audio_path=transcript.audio_mp3_filename, + waveform_path=transcript.audio_waveform_filename, + on_waveform=self.on_waveform, + ) + waveform_processor.set_pipeline(self.empty_pipeline) + await waveform_processor.flush() + self.logger.info("Waveform generated successfully") speaker_transcripts: list[TranscriptType] = [] for idx, padded_url in enumerate(padded_track_urls): diff --git a/server/reflector/settings.py b/server/reflector/settings.py index 338e1da9..1ec46d94 100644 --- a/server/reflector/settings.py +++ b/server/reflector/settings.py @@ -138,14 +138,6 @@ class Settings(BaseSettings): DAILY_WEBHOOK_UUID: str | None = ( None # Webhook UUID for this environment. Not used by production code ) - - # Multitrack processing - # SKIP_MIXDOWN: When True, skips audio mixdown and waveform generation. - # Transcription still works using individual tracks. Useful for: - # - Diagnosing OOM issues in mixdown - # - Fast processing when audio playback is not needed - # Note: UI will have no audio playback or waveform when enabled. - SKIP_MIXDOWN: bool = True # Platform Configuration DEFAULT_VIDEO_PLATFORM: Platform = WHEREBY_PLATFORM From af921ce927edb8d092c817185d4d50bf09a67cad Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Tue, 2 Dec 2025 16:11:48 -0600 Subject: [PATCH 23/26] chore(main): release 0.22.4 (#765) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4b5db73..f4452534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.22.4](https://github.com/Monadical-SAS/reflector/compare/v0.22.3...v0.22.4) (2025-12-02) + + +### Bug Fixes + +* Multitrack mixdown optimisation 2 ([#764](https://github.com/Monadical-SAS/reflector/issues/764)) ([bd5df1c](https://github.com/Monadical-SAS/reflector/commit/bd5df1ce2ebf35d7f3413b295e56937a9a28ef7b)) + ## [0.22.3](https://github.com/Monadical-SAS/reflector/compare/v0.22.2...v0.22.3) (2025-12-02) From d3a5cd12d2d0d9c32af2d5bd9322e030ef69b85d Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Wed, 3 Dec 2025 16:47:56 +0100 Subject: [PATCH 24/26] fix: return participant emails from transcript endpoint (#769) * Return participant emails from transcript endpoint * Fix broken test --- server/reflector/db/users.py | 6 ++++++ server/reflector/views/transcripts.py | 21 +++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/server/reflector/db/users.py b/server/reflector/db/users.py index ccbe11d6..1224a3bb 100644 --- a/server/reflector/db/users.py +++ b/server/reflector/db/users.py @@ -88,5 +88,11 @@ class UserController: results = await get_database().fetch_all(query) return [User(**r) for r in results] + @staticmethod + async def get_by_ids(user_ids: list[NonEmptyString]) -> dict[str, User]: + query = users.select().where(users.c.id.in_(user_ids)) + results = await get_database().fetch_all(query) + return {user.id: User(**user) for user in results} + user_controller = UserController() diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py index 625a9896..27663dc6 100644 --- a/server/reflector/views/transcripts.py +++ b/server/reflector/views/transcripts.py @@ -37,6 +37,7 @@ from reflector.db.transcripts import ( TranscriptTopic, transcripts_controller, ) +from reflector.db.users import user_controller from reflector.processors.types import Transcript as ProcessorTranscript from reflector.processors.types import Word from reflector.schemas.transcript_formats import TranscriptFormat, TranscriptSegment @@ -111,8 +112,12 @@ class GetTranscriptMinimal(BaseModel): audio_deleted: bool | None = None +class TranscriptParticipantWithEmail(TranscriptParticipant): + email: str | None = None + + class GetTranscriptWithParticipants(GetTranscriptMinimal): - participants: list[TranscriptParticipant] | None + participants: list[TranscriptParticipantWithEmail] | None class GetTranscriptWithText(GetTranscriptWithParticipants): @@ -468,6 +473,18 @@ async def transcript_get( is_multitrack = await _get_is_multitrack(transcript) + participants = [] + if transcript.participants: + user_ids = [p.user_id for p in transcript.participants if p.user_id is not None] + users_dict = await user_controller.get_by_ids(user_ids) if user_ids else {} + for p in transcript.participants: + user = users_dict.get(p.user_id) if p.user_id else None + participants.append( + TranscriptParticipantWithEmail( + **p.model_dump(), email=user.email if user else None + ) + ) + base_data = { "id": transcript.id, "user_id": transcript.user_id, @@ -487,7 +504,7 @@ async def transcript_get( "source_kind": transcript.source_kind, "room_id": transcript.room_id, "audio_deleted": transcript.audio_deleted, - "participants": transcript.participants, + "participants": participants, } if transcript_format == "text": From 3ad78be7628c0d029296b301a0e87236c76b7598 Mon Sep 17 00:00:00 2001 From: Sergey Mankovsky Date: Wed, 3 Dec 2025 16:49:17 +0100 Subject: [PATCH 25/26] fix: hide rooms settings instead of disabling (#763) * Hide rooms settings instead of disabling * Reset recording trigger --- www/app/(app)/rooms/page.tsx | 220 +++++++++++++++++++++++------------ 1 file changed, 147 insertions(+), 73 deletions(-) diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 198df774..147f8351 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -15,9 +15,12 @@ import { createListCollection, useDisclosure, Tabs, + Popover, + Text, + HStack, } from "@chakra-ui/react"; import { useEffect, useMemo, useState } from "react"; -import { LuEye, LuEyeOff } from "react-icons/lu"; +import { LuEye, LuEyeOff, LuInfo } from "react-icons/lu"; import useRoomList from "./useRoomList"; import type { components } from "../../reflector-api"; import { @@ -534,6 +537,10 @@ export default function RoomsList() { room.recordingType === "cloud" ? "automatic-2nd-participant" : "none"; + } else { + if (room.recordingType !== "cloud") { + updates.recordingTrigger = "none"; + } } setRoomInput({ ...room, ...updates }); }} @@ -583,39 +590,75 @@ export default function RoomsList() { Locked room + {room.platform !== "daily" && ( + + Room size + + setRoomInput({ ...room, roomMode: e.value[0] }) + } + collection={roomModeCollection} + > + + + + + + + + + + + + {roomModeOptions.map((option) => ( + + {option.label} + + + ))} + + + + + )} - Room size - - setRoomInput({ ...room, roomMode: e.value[0] }) - } - collection={roomModeCollection} - disabled={room.platform === "daily"} - > - - - - - - - - - - - - {roomModeOptions.map((option) => ( - - {option.label} - - - ))} - - - - - - Recording type + + Recording type + + + + + + + + + + + + None: No recording will be + created. +
+
+ Local: Recording happens on + each participant's device. Files are saved + locally. +
+
+ Cloud: Recording happens on + the platform's servers and is available after + the meeting ends. +
+
+
+
+
+
{ @@ -623,14 +666,12 @@ export default function RoomsList() { const updates: Partial = { recordingType: newRecordingType, }; - // For Daily: if cloud, use automatic; otherwise none if (room.platform === "daily") { updates.recordingTrigger = newRecordingType === "cloud" ? "automatic-2nd-participant" : "none"; } else { - // For Whereby: if not cloud, set to none updates.recordingTrigger = newRecordingType !== "cloud" ? "none" @@ -661,44 +702,77 @@ export default function RoomsList() {
- - Recording start trigger - - setRoomInput({ - ...room, - recordingTrigger: e.value[0], - }) - } - collection={recordingTriggerCollection} - disabled={ - room.recordingType !== "cloud" || - (room.platform === "daily" && - room.recordingType === "cloud") - } - > - - - - - - - - - - - - {recordingTriggerOptions.map((option) => ( - - {option.label} - - - ))} - - - - + {room.recordingType === "cloud" && + room.platform !== "daily" && ( + + + Recording start trigger + + + + + + + + + + + + None: Recording must be + started manually by a participant. +
+
+ Prompt: Participants will + be prompted to start recording when they + join. +
+
+ Automatic: Recording + starts automatically when a second + participant joins. +
+
+
+
+
+
+ + setRoomInput({ + ...room, + recordingTrigger: e.value[0], + }) + } + collection={recordingTriggerCollection} + > + + + + + + + + + + + + {recordingTriggerOptions.map((option) => ( + + {option.label} + + + ))} + + + +
+ )} Date: Wed, 3 Dec 2025 13:26:08 -0500 Subject: [PATCH 26/26] feat: dockerhub ci (#772) * dockerhub ci * ci test --------- Co-authored-by: Igor Loskutov --- .github/workflows/dockerhub-backend.yml | 58 +++++++++++++++++++ ...er-frontend.yml => dockerhub-frontend.yml} | 15 ++--- 2 files changed, 66 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/dockerhub-backend.yml rename .github/workflows/{docker-frontend.yml => dockerhub-frontend.yml} (81%) diff --git a/.github/workflows/dockerhub-backend.yml b/.github/workflows/dockerhub-backend.yml new file mode 100644 index 00000000..cd23f74c --- /dev/null +++ b/.github/workflows/dockerhub-backend.yml @@ -0,0 +1,58 @@ +name: Build and Push Backend Docker Image (Docker Hub) + +on: + push: + branches: + - main + - dockerhub-2 + paths: + - 'server/**' + - '.github/workflows/dockerhub-backend.yml' + workflow_dispatch: + +env: + REGISTRY: docker.io + IMAGE_NAME: monadicalsas/reflector-backend + +jobs: + build-and-push: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: monadicalsas + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./server + file: ./server/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/docker-frontend.yml b/.github/workflows/dockerhub-frontend.yml similarity index 81% rename from .github/workflows/docker-frontend.yml rename to .github/workflows/dockerhub-frontend.yml index ea861782..2dc25f42 100644 --- a/.github/workflows/docker-frontend.yml +++ b/.github/workflows/dockerhub-frontend.yml @@ -4,32 +4,33 @@ on: push: branches: - main + - dockerhub-2 paths: - 'www/**' - - '.github/workflows/docker-frontend.yml' + - '.github/workflows/dockerhub-frontend.yml' workflow_dispatch: env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }}-frontend + REGISTRY: docker.io + IMAGE_NAME: monadicalsas/reflector-frontend jobs: build-and-push: runs-on: ubuntu-latest + permissions: contents: read - packages: write steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Log in to GitHub Container Registry + - name: Log in to Docker Hub uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + username: monadicalsas + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Extract metadata id: meta