mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| af921ce927 | |||
|
|
bd5df1ce2e | ||
| c8024484b3 | |||
| 28f87c09dc | |||
| dabf7251db | |||
|
|
b51b7aa917 | ||
|
|
a8983b4e7e | ||
|
|
fe47c46489 | ||
| a2bb6a27d6 | |||
| 7f0b728991 | |||
| 692895c859 | |||
|
|
d63040e2fd |
36
CHANGELOG.md
36
CHANGELOG.md
@@ -1,5 +1,41 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
### 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)
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -414,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(
|
||||
@@ -443,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:
|
||||
@@ -462,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()
|
||||
@@ -494,6 +510,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 +602,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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 RecordingResponse
|
||||
from reflector.db.daily_participant_sessions import (
|
||||
DailyParticipantSession,
|
||||
daily_participant_sessions_controller,
|
||||
@@ -21,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
|
||||
@@ -38,7 +38,7 @@ from reflector.storage import get_transcripts_storage
|
||||
from reflector.utils.daily import (
|
||||
DailyRoomName,
|
||||
extract_base_room_name,
|
||||
parse_daily_recording_filename,
|
||||
filter_cam_audio_tracks,
|
||||
recording_lock_key,
|
||||
)
|
||||
from reflector.video_platforms.factory import create_platform_client
|
||||
@@ -273,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,
|
||||
@@ -294,79 +286,10 @@ 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,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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,
|
||||
track_keys=track_keys,
|
||||
track_keys=filter_cam_audio_tracks(track_keys),
|
||||
)
|
||||
|
||||
|
||||
@@ -391,7 +314,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 +345,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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | typeof roomInitialState>(
|
||||
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() {
|
||||
)}
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root mt={4}>
|
||||
<Field.Label>Platform</Field.Label>
|
||||
<Select.Root
|
||||
value={[room.platform]}
|
||||
onValueChange={(e) => {
|
||||
const newPlatform = e.value[0] as "whereby" | "daily";
|
||||
const updates: Partial<typeof room> = {
|
||||
platform: newPlatform,
|
||||
};
|
||||
if (newPlatform === "daily") {
|
||||
updates.roomMode = "group";
|
||||
updates.recordingTrigger =
|
||||
room.recordingType === "cloud"
|
||||
? "automatic-2nd-participant"
|
||||
: "none";
|
||||
}
|
||||
setRoomInput({ ...room, ...updates });
|
||||
}}
|
||||
collection={platformCollection}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText placeholder="Select platform" />
|
||||
</Select.Trigger>
|
||||
<Select.IndicatorGroup>
|
||||
<Select.Indicator />
|
||||
</Select.IndicatorGroup>
|
||||
</Select.Control>
|
||||
<Select.Positioner>
|
||||
<Select.Content>
|
||||
{platformOptions.map((option) => (
|
||||
<Select.Item key={option.value} item={option}>
|
||||
{option.label}
|
||||
<Select.ItemIndicator />
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Positioner>
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root mt={4}>
|
||||
<Checkbox.Root
|
||||
name="isLocked"
|
||||
@@ -512,6 +591,7 @@ export default function RoomsList() {
|
||||
setRoomInput({ ...room, roomMode: e.value[0] })
|
||||
}
|
||||
collection={roomModeCollection}
|
||||
disabled={room.platform === "daily"}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
@@ -538,16 +618,26 @@ export default function RoomsList() {
|
||||
<Field.Label>Recording type</Field.Label>
|
||||
<Select.Root
|
||||
value={[room.recordingType]}
|
||||
onValueChange={(e) =>
|
||||
setRoomInput({
|
||||
...room,
|
||||
recordingType: e.value[0],
|
||||
recordingTrigger:
|
||||
e.value[0] !== "cloud"
|
||||
onValueChange={(e) => {
|
||||
const newRecordingType = e.value[0];
|
||||
const updates: Partial<typeof room> = {
|
||||
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}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
@@ -572,7 +662,7 @@ export default function RoomsList() {
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
<Field.Root mt={4}>
|
||||
<Field.Label>Cloud recording start trigger</Field.Label>
|
||||
<Field.Label>Recording start trigger</Field.Label>
|
||||
<Select.Root
|
||||
value={[room.recordingTrigger]}
|
||||
onValueChange={(e) =>
|
||||
@@ -582,7 +672,11 @@ export default function RoomsList() {
|
||||
})
|
||||
}
|
||||
collection={recordingTriggerCollection}
|
||||
disabled={room.recordingType !== "cloud"}
|
||||
disabled={
|
||||
room.recordingType !== "cloud" ||
|
||||
(room.platform === "daily" &&
|
||||
room.recordingType === "cloud")
|
||||
}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
|
||||
@@ -117,15 +117,6 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
return <Modal title="Loading" text={"Loading transcript..."} />;
|
||||
}
|
||||
|
||||
if (mp3.error) {
|
||||
return (
|
||||
<Modal
|
||||
title="Transcription error"
|
||||
text={`There was an error loading the recording. Error: ${mp3.error}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid
|
||||
@@ -147,7 +138,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
|
||||
/>
|
||||
) : !mp3.loading && (waveform.error || mp3.error) ? (
|
||||
<Box p={4} bg="red.100" borderRadius="md">
|
||||
<Text>Error loading this recording</Text>
|
||||
<Text>
|
||||
Error loading{" "}
|
||||
{[waveform.error && "waveform", mp3.error && "mp3"]
|
||||
.filter(Boolean)
|
||||
.join(" and ")}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Skeleton h={14} />
|
||||
|
||||
@@ -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,15 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const auth = useAuth();
|
||||
const status = auth.status;
|
||||
const authLastUserId = auth.lastUserId;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const joinMutation = useRoomJoinMeeting();
|
||||
const [joinedMeeting, setJoinedMeeting] = useState<Meeting | null>(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 === undefined || !meeting?.id || !roomName) return;
|
||||
|
||||
const join = async () => {
|
||||
try {
|
||||
@@ -50,18 +50,17 @@ 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 roomUrl = joinedMeeting?.room_url;
|
||||
|
||||
const handleLeave = useCallback(() => {
|
||||
router.push("/browse");
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !roomUrl || !containerRef.current) return;
|
||||
if (authLastUserId === undefined || !roomUrl || !containerRef.current)
|
||||
return;
|
||||
|
||||
let frame: DailyCall | null = null;
|
||||
let destroyed = false;
|
||||
@@ -92,7 +91,15 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
|
||||
|
||||
frame.on("joined-meeting", async () => {
|
||||
try {
|
||||
await frame.startRecording({ type: "raw-tracks" });
|
||||
const frameInstance = assertExists(
|
||||
frame,
|
||||
"frame object got lost somewhere after frame.on was called",
|
||||
);
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -104,7 +111,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 +123,9 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [roomUrl, isLoading, handleLeave]);
|
||||
}, [roomUrl, authLastUserId, handleLeave]);
|
||||
|
||||
if (isLoading) {
|
||||
if (authLastUserId === undefined) {
|
||||
return (
|
||||
<Center width="100vw" height="100vh">
|
||||
<Spinner size="xl" />
|
||||
|
||||
@@ -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,9 @@ type AuthContextType = (
|
||||
update: () => Promise<Session | null>;
|
||||
signIn: typeof signIn;
|
||||
signOut: typeof signOut;
|
||||
// TODO probably rename isLoading to isReloading and make THIS field "isLoading"
|
||||
// undefined is "not known", null is "is certainly logged out"
|
||||
lastUserId: CustomSession["user"]["id"] | null | undefined;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
@@ -41,10 +44,15 @@ const noopAuthContext: AuthContextType = {
|
||||
signOut: async () => {
|
||||
throw new Error("signOut not supposed to be called");
|
||||
},
|
||||
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<CustomSession["user"]["id"] | null | undefined>(
|
||||
null,
|
||||
);
|
||||
|
||||
const contextValue: AuthContextType = isAuthEnabled
|
||||
? {
|
||||
@@ -73,11 +81,16 @@ 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,
|
||||
};
|
||||
} 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,
|
||||
@@ -92,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: {
|
||||
@@ -103,6 +118,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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user