From 7030e0f23649a8cf6c1eb6d5889684a41ce849ec Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Wed, 27 Aug 2025 10:32:04 -0600
Subject: [PATCH 01/77] fix: optimize parakeet transcription batching algorithm
(#577)
* refactor: optimize transcription batching to accumulate speech segments
- Changed VAD segment generator to return full audio array instead of segments
- Removed segment filtering step
- Modified batch_segments to accumulate maximum speech including silence
- Transcribe larger continuous chunks instead of individual speech segments
* fix: correct transcribe_batch call to use list and fix batch unpacking
* fix: simplify
* fix: remove unused variables
* fix: add typing
---
.../reflector_transcriber_parakeet.py | 129 +++++++-----------
1 file changed, 53 insertions(+), 76 deletions(-)
diff --git a/server/gpu/modal_deployments/reflector_transcriber_parakeet.py b/server/gpu/modal_deployments/reflector_transcriber_parakeet.py
index 97e150e3..3b6f6ad0 100644
--- a/server/gpu/modal_deployments/reflector_transcriber_parakeet.py
+++ b/server/gpu/modal_deployments/reflector_transcriber_parakeet.py
@@ -3,7 +3,7 @@ import os
import sys
import threading
import uuid
-from typing import Mapping, NewType
+from typing import Generator, Mapping, NewType
from urllib.parse import urlparse
import modal
@@ -14,10 +14,7 @@ SAMPLERATE = 16000
UPLOADS_PATH = "/uploads"
CACHE_PATH = "/cache"
VAD_CONFIG = {
- "max_segment_duration": 30.0,
- "batch_max_files": 10,
- "batch_max_duration": 5.0,
- "min_segment_duration": 0.02,
+ "batch_max_duration": 30.0,
"silence_padding": 0.5,
"window_size": 512,
}
@@ -271,7 +268,9 @@ class TranscriberParakeetFile:
audio_array, sample_rate = librosa.load(file_path, sr=SAMPLERATE, mono=True)
return audio_array
- def vad_segment_generator(audio_array):
+ def vad_segment_generator(
+ audio_array,
+ ) -> Generator[tuple[float, float], None, None]:
"""Generate speech segments using VAD with start/end sample indices"""
vad_iterator = VADIterator(self.vad_model, sampling_rate=SAMPLERATE)
window_size = VAD_CONFIG["window_size"]
@@ -297,76 +296,65 @@ class TranscriberParakeetFile:
start_time = start / float(SAMPLERATE)
end_time = end / float(SAMPLERATE)
- # Extract the actual audio segment
- audio_segment = audio_array[start:end]
-
- yield (start_time, end_time, audio_segment)
+ yield (start_time, end_time)
start = None
vad_iterator.reset_states()
- def vad_segment_filter(segments):
- """Filter VAD segments by duration and chunk large segments"""
- min_dur = VAD_CONFIG["min_segment_duration"]
- max_dur = VAD_CONFIG["max_segment_duration"]
+ def batch_speech_segments(
+ segments: Generator[tuple[float, float], None, None], max_duration: int
+ ) -> Generator[tuple[float, float], None, None]:
+ """
+ Input segments:
+ [0-2] [3-5] [6-8] [10-11] [12-15] [17-19] [20-22]
- for start_time, end_time, audio_segment in segments:
- segment_duration = end_time - start_time
+ ↓ (max_duration=10)
- # Skip very small segments
- if segment_duration < min_dur:
+ Output batches:
+ [0-8] [10-19] [20-22]
+
+ Note: silences are kept for better transcription, previous implementation was
+ passing segments separatly, but the output was less accurate.
+ """
+ batch_start_time = None
+ batch_end_time = None
+
+ for start_time, end_time in segments:
+ if batch_start_time is None or batch_end_time is None:
+ batch_start_time = start_time
+ batch_end_time = end_time
continue
- # If segment is within max duration, yield as-is
- if segment_duration <= max_dur:
- yield (start_time, end_time, audio_segment)
+ total_duration = end_time - batch_start_time
+
+ if total_duration <= max_duration:
+ batch_end_time = end_time
continue
- # Chunk large segments into smaller pieces
- chunk_samples = int(max_dur * SAMPLERATE)
- current_start = start_time
+ yield (batch_start_time, batch_end_time)
+ batch_start_time = start_time
+ batch_end_time = end_time
- for chunk_offset in range(0, len(audio_segment), chunk_samples):
- chunk_audio = audio_segment[
- chunk_offset : chunk_offset + chunk_samples
- ]
- if len(chunk_audio) == 0:
- break
+ if batch_start_time is None or batch_end_time is None:
+ return
- chunk_duration = len(chunk_audio) / float(SAMPLERATE)
- chunk_end = current_start + chunk_duration
+ yield (batch_start_time, batch_end_time)
- # Only yield chunks that meet minimum duration
- if chunk_duration >= min_dur:
- yield (current_start, chunk_end, chunk_audio)
+ def batch_segment_to_audio_segment(segments, audio_array):
+ for start_time, end_time in segments:
+ start_sample = int(start_time * SAMPLERATE)
+ end_sample = int(end_time * SAMPLERATE)
+ audio_segment = audio_array[start_sample:end_sample]
- current_start = chunk_end
-
- def batch_segments(segments, max_files=10, max_duration=5.0):
- batch = []
- batch_duration = 0.0
-
- for start_time, end_time, audio_segment in segments:
- segment_duration = end_time - start_time
-
- if segment_duration < VAD_CONFIG["silence_padding"]:
+ if end_time - start_time < VAD_CONFIG["silence_padding"]:
silence_samples = int(
- (VAD_CONFIG["silence_padding"] - segment_duration) * SAMPLERATE
+ (VAD_CONFIG["silence_padding"] - (end_time - start_time))
+ * SAMPLERATE
)
padding = np.zeros(silence_samples, dtype=np.float32)
audio_segment = np.concatenate([audio_segment, padding])
- segment_duration = VAD_CONFIG["silence_padding"]
- batch.append((start_time, end_time, audio_segment))
- batch_duration += segment_duration
-
- if len(batch) >= max_files or batch_duration >= max_duration:
- yield batch
- batch = []
- batch_duration = 0.0
-
- if batch:
- yield batch
+ yield start_time, end_time, audio_segment
def transcribe_batch(model, audio_segments):
with NoStdStreams():
@@ -376,8 +364,6 @@ class TranscriberParakeetFile:
def emit_results(
results,
segments_info,
- batch_index,
- total_batches,
):
"""Yield transcribed text and word timings from model output, adjusting timestamps to absolute positions."""
for i, (output, (start_time, end_time, _)) in enumerate(
@@ -413,35 +399,26 @@ class TranscriberParakeetFile:
all_words = []
raw_segments = vad_segment_generator(audio_array)
- filtered_segments = vad_segment_filter(raw_segments)
- batches = batch_segments(
- filtered_segments,
- VAD_CONFIG["batch_max_files"],
+ speech_segments = batch_speech_segments(
+ raw_segments,
VAD_CONFIG["batch_max_duration"],
)
+ audio_segments = batch_segment_to_audio_segment(speech_segments, audio_array)
- batch_index = 0
- total_batches = max(
- 1, int(total_duration / VAD_CONFIG["batch_max_duration"]) + 1
- )
-
- for batch in batches:
- batch_index += 1
- audio_segments = [seg[2] for seg in batch]
- results = transcribe_batch(self.model, audio_segments)
+ for batch in audio_segments:
+ _, _, audio_segment = batch
+ results = transcribe_batch(self.model, [audio_segment])
for text, words in emit_results(
results,
- batch,
- batch_index,
- total_batches,
+ [batch],
):
if not text:
continue
all_text_parts.append(text)
all_words.extend(words)
- processed_duration += sum(len(seg[2]) / float(SAMPLERATE) for seg in batch)
+ processed_duration += len(audio_segment) / float(SAMPLERATE)
combined_text = " ".join(all_text_parts)
return {"text": combined_text, "words": all_words}
From 124ce03bf86044c18313d27228a25da4bc20c9c5 Mon Sep 17 00:00:00 2001
From: Igor Loskutov
Date: Thu, 28 Aug 2025 12:07:34 -0400
Subject: [PATCH 02/77] fix: Igor/evaluation (#575)
* fix: impossible import error (#563)
* evaluation cli - database events experiment
* hallucinations
* evaluation - unhallucinate
* evaluation - unhallucinate
* roll back reliability link
* self reviewio
* lint
* self review
* add file pipeline to cli
* add file pipeline to cli + sorting
* remove cli tests
* remove ai comments
* comments
---
.../reflector/pipelines/main_live_pipeline.py | 2 +-
.../processors/file_transcript_modal.py | 3 +
server/reflector/tools/process.py | 496 ++++++------------
.../tools/process_with_diarization.py | 318 -----------
server/reflector/tools/test_diarization.py | 96 ----
server/tests/test_processors_pipeline.py | 61 ---
6 files changed, 173 insertions(+), 803 deletions(-)
delete mode 100644 server/reflector/tools/process_with_diarization.py
delete mode 100644 server/reflector/tools/test_diarization.py
delete mode 100644 server/tests/test_processors_pipeline.py
diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py
index b15fcb05..812847db 100644
--- a/server/reflector/pipelines/main_live_pipeline.py
+++ b/server/reflector/pipelines/main_live_pipeline.py
@@ -794,7 +794,7 @@ def pipeline_post(*, transcript_id: str):
chain_final_summaries,
) | task_pipeline_post_to_zulip.si(transcript_id=transcript_id)
- chain.delay()
+ return chain.delay()
@get_transcript
diff --git a/server/reflector/processors/file_transcript_modal.py b/server/reflector/processors/file_transcript_modal.py
index 21c378ec..b99cf806 100644
--- a/server/reflector/processors/file_transcript_modal.py
+++ b/server/reflector/processors/file_transcript_modal.py
@@ -67,6 +67,9 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
for word_info in result.get("words", [])
]
+ # words come not in order
+ words.sort(key=lambda w: w.start)
+
return Transcript(words=words)
diff --git a/server/reflector/tools/process.py b/server/reflector/tools/process.py
index 4f1cafdd..eb770f76 100644
--- a/server/reflector/tools/process.py
+++ b/server/reflector/tools/process.py
@@ -1,294 +1,204 @@
"""
Process audio file with diarization support
-===========================================
-
-Extended version of process.py that includes speaker diarization.
-This tool processes audio files locally without requiring the full server infrastructure.
"""
+import argparse
import asyncio
-import tempfile
-import uuid
+import json
+import shutil
+import sys
+import time
from pathlib import Path
-from typing import List
-
-import av
+from typing import Any, Dict, List, Literal
+from reflector.db.transcripts import SourceKind, TranscriptTopic, transcripts_controller
from reflector.logger import logger
-from reflector.processors import (
- AudioChunkerAutoProcessor,
- AudioDownscaleProcessor,
- AudioFileWriterProcessor,
- AudioMergeProcessor,
- AudioTranscriptAutoProcessor,
- Pipeline,
- PipelineEvent,
- TranscriptFinalSummaryProcessor,
- TranscriptFinalTitleProcessor,
- TranscriptLinerProcessor,
- TranscriptTopicDetectorProcessor,
- TranscriptTranslatorAutoProcessor,
+from reflector.pipelines.main_file_pipeline import (
+ task_pipeline_file_process as task_pipeline_file_process,
)
-from reflector.processors.base import BroadcastProcessor, Processor
-from reflector.processors.types import (
- AudioDiarizationInput,
- TitleSummary,
- TitleSummaryWithId,
+from reflector.pipelines.main_live_pipeline import pipeline_post as live_pipeline_post
+from reflector.pipelines.main_live_pipeline import (
+ pipeline_process as live_pipeline_process,
)
-class TopicCollectorProcessor(Processor):
- """Collect topics for diarization"""
+def serialize_topics(topics: List[TranscriptTopic]) -> List[Dict[str, Any]]:
+ """Convert TranscriptTopic objects to JSON-serializable dicts"""
+ serialized = []
+ for topic in topics:
+ topic_dict = topic.model_dump()
+ serialized.append(topic_dict)
+ return serialized
- INPUT_TYPE = TitleSummary
- OUTPUT_TYPE = TitleSummary
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.topics: List[TitleSummaryWithId] = []
- self._topic_id = 0
+def debug_print_speakers(serialized_topics: List[Dict[str, Any]]) -> None:
+ """Print debug info about speakers found in topics"""
+ all_speakers = set()
+ for topic_dict in serialized_topics:
+ for word in topic_dict.get("words", []):
+ all_speakers.add(word.get("speaker", 0))
- async def _push(self, data: TitleSummary):
- # Convert to TitleSummaryWithId and collect
- self._topic_id += 1
- topic_with_id = TitleSummaryWithId(
- id=str(self._topic_id),
- title=data.title,
- summary=data.summary,
- timestamp=data.timestamp,
- duration=data.duration,
- transcript=data.transcript,
+ print(
+ f"Found {len(serialized_topics)} topics with speakers: {all_speakers}",
+ file=sys.stderr,
+ )
+
+
+TranscriptId = str
+
+
+# common interface for every flow: it needs an Entry in db with specific ceremony (file path + status + actual file in file system)
+# ideally we want to get rid of it at some point
+async def prepare_entry(
+ source_path: str,
+ source_language: str,
+ target_language: str,
+) -> TranscriptId:
+ file_path = Path(source_path)
+
+ transcript = await transcripts_controller.add(
+ file_path.name,
+ # note that the real file upload has SourceKind: LIVE for the reason of it's an error
+ source_kind=SourceKind.FILE,
+ source_language=source_language,
+ target_language=target_language,
+ user_id=None,
+ )
+
+ logger.info(
+ f"Created empty transcript {transcript.id} for file {file_path.name} because technically we need an empty transcript before we start transcript"
+ )
+
+ # pipelines expect files as upload.*
+
+ extension = file_path.suffix
+ upload_path = transcript.data_path / f"upload{extension}"
+ upload_path.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(source_path, upload_path)
+ logger.info(f"Copied {source_path} to {upload_path}")
+
+ # pipelines expect entity status "uploaded"
+ await transcripts_controller.update(transcript, {"status": "uploaded"})
+
+ return transcript.id
+
+
+# same reason as prepare_entry
+async def extract_result_from_entry(
+ transcript_id: TranscriptId, output_path: str
+) -> None:
+ post_final_transcript = await transcripts_controller.get_by_id(transcript_id)
+
+ # assert post_final_transcript.status == "ended"
+ # File pipeline doesn't set status to "ended", only live pipeline does https://github.com/Monadical-SAS/reflector/issues/582
+ topics = post_final_transcript.topics
+ if not topics:
+ raise RuntimeError(
+ f"No topics found for transcript {transcript_id} after processing"
)
- self.topics.append(topic_with_id)
- # Pass through the original topic
- await self.emit(data)
+ serialized_topics = serialize_topics(topics)
- def get_topics(self) -> List[TitleSummaryWithId]:
- return self.topics
+ if output_path:
+ # Write to JSON file
+ with open(output_path, "w") as f:
+ for topic_dict in serialized_topics:
+ json.dump(topic_dict, f)
+ f.write("\n")
+ print(f"Results written to {output_path}", file=sys.stderr)
+ else:
+ # Write to stdout as JSONL
+ for topic_dict in serialized_topics:
+ print(json.dumps(topic_dict))
+
+ debug_print_speakers(serialized_topics)
-async def process_audio_file(
- filename,
- event_callback,
- only_transcript=False,
- source_language="en",
- target_language="en",
- enable_diarization=True,
- diarization_backend="pyannote",
+async def process_live_pipeline(
+ transcript_id: TranscriptId,
):
- # Create temp file for audio if diarization is enabled
- audio_temp_path = None
- if enable_diarization:
- audio_temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
- audio_temp_path = audio_temp_file.name
- audio_temp_file.close()
+ """Process transcript_id with transcription and diarization"""
- # Create processor for collecting topics
- topic_collector = TopicCollectorProcessor()
+ print(f"Processing transcript_id {transcript_id}...", file=sys.stderr)
+ await live_pipeline_process(transcript_id=transcript_id)
+ print(f"Processing complete for transcript {transcript_id}", file=sys.stderr)
- # Build pipeline for audio processing
- processors = []
+ pre_final_transcript = await transcripts_controller.get_by_id(transcript_id)
- # Add audio file writer at the beginning if diarization is enabled
- if enable_diarization:
- processors.append(AudioFileWriterProcessor(audio_temp_path))
+ # assert documented behaviour: after process, the pipeline isn't ended. this is the reason of calling pipeline_post
+ assert pre_final_transcript.status != "ended"
- # Add the rest of the processors
- processors += [
- AudioDownscaleProcessor(),
- AudioChunkerAutoProcessor(),
- AudioMergeProcessor(),
- AudioTranscriptAutoProcessor.as_threaded(),
- TranscriptLinerProcessor(),
- TranscriptTranslatorAutoProcessor.as_threaded(),
- ]
+ # at this point, diarization is running but we have no access to it. run diarization in parallel - one will hopefully win after polling
+ result = live_pipeline_post(transcript_id=transcript_id)
- if not only_transcript:
- processors += [
- TranscriptTopicDetectorProcessor.as_threaded(),
- # Collect topics for diarization
- topic_collector,
- BroadcastProcessor(
- processors=[
- TranscriptFinalTitleProcessor.as_threaded(),
- TranscriptFinalSummaryProcessor.as_threaded(),
- ],
- ),
- ]
-
- # Create main pipeline
- pipeline = Pipeline(*processors)
- pipeline.set_pref("audio:source_language", source_language)
- pipeline.set_pref("audio:target_language", target_language)
- pipeline.describe()
- pipeline.on(event_callback)
-
- # Start processing audio
- logger.info(f"Opening {filename}")
- container = av.open(filename)
- try:
- logger.info("Start pushing audio into the pipeline")
- for frame in container.decode(audio=0):
- await pipeline.push(frame)
- finally:
- logger.info("Flushing the pipeline")
- await pipeline.flush()
-
- # Run diarization if enabled and we have topics
- if enable_diarization and not only_transcript and audio_temp_path:
- topics = topic_collector.get_topics()
-
- if topics:
- logger.info(f"Starting diarization with {len(topics)} topics")
-
- try:
- from reflector.processors import AudioDiarizationAutoProcessor
-
- diarization_processor = AudioDiarizationAutoProcessor(
- name=diarization_backend
- )
-
- diarization_processor.set_pipeline(pipeline)
-
- # For Modal backend, we need to upload the file to S3 first
- if diarization_backend == "modal":
- from datetime import datetime
-
- from reflector.storage import get_transcripts_storage
- from reflector.utils.s3_temp_file import S3TemporaryFile
-
- storage = get_transcripts_storage()
-
- # Generate a unique filename in evaluation folder
- timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
- audio_filename = f"evaluation/diarization_temp/{timestamp}_{uuid.uuid4().hex}.wav"
-
- # Use context manager for automatic cleanup
- async with S3TemporaryFile(storage, audio_filename) as s3_file:
- # Read and upload the audio file
- with open(audio_temp_path, "rb") as f:
- audio_data = f.read()
-
- audio_url = await s3_file.upload(audio_data)
- logger.info(f"Uploaded audio to S3: {audio_filename}")
-
- # Create diarization input with S3 URL
- diarization_input = AudioDiarizationInput(
- audio_url=audio_url, topics=topics
- )
-
- # Run diarization
- await diarization_processor.push(diarization_input)
- await diarization_processor.flush()
-
- logger.info("Diarization complete")
- # File will be automatically cleaned up when exiting the context
- else:
- # For local backend, use local file path
- audio_url = audio_temp_path
-
- # Create diarization input
- diarization_input = AudioDiarizationInput(
- audio_url=audio_url, topics=topics
- )
-
- # Run diarization
- await diarization_processor.push(diarization_input)
- await diarization_processor.flush()
-
- logger.info("Diarization complete")
-
- except ImportError as e:
- logger.error(f"Failed to import diarization dependencies: {e}")
- logger.error(
- "Install with: uv pip install pyannote.audio torch torchaudio"
- )
- logger.error(
- "And set HF_TOKEN environment variable for pyannote models"
- )
- raise SystemExit(1)
- except Exception as e:
- logger.error(f"Diarization failed: {e}")
- raise SystemExit(1)
- else:
- logger.warning("Skipping diarization: no topics available")
-
- # Clean up temp file
- if audio_temp_path:
- try:
- Path(audio_temp_path).unlink()
- except Exception as e:
- logger.warning(f"Failed to clean up temp file {audio_temp_path}: {e}")
-
- logger.info("All done!")
+ # result.ready() blocks even without await; it mutates result also
+ while not result.ready():
+ print(f"Status: {result.state}")
+ time.sleep(2)
async def process_file_pipeline(
- filename: str,
- event_callback,
- source_language="en",
- target_language="en",
- enable_diarization=True,
- diarization_backend="modal",
+ transcript_id: TranscriptId,
):
"""Process audio/video file using the optimized file pipeline"""
+
+ # task_pipeline_file_process is a Celery task, need to use .delay() for async execution
+ result = task_pipeline_file_process.delay(transcript_id=transcript_id)
+
+ # Wait for the Celery task to complete
+ while not result.ready():
+ print(f"File pipeline status: {result.state}", file=sys.stderr)
+ time.sleep(2)
+
+ logger.info("File pipeline processing complete")
+
+
+async def process(
+ source_path: str,
+ source_language: str,
+ target_language: str,
+ pipeline: Literal["live", "file"],
+ output_path: str = None,
+):
+ from reflector.db import get_database
+
+ database = get_database()
+ # db connect is a part of ceremony
+ await database.connect()
+
try:
- from reflector.db import database
- from reflector.db.transcripts import SourceKind, transcripts_controller
- from reflector.pipelines.main_file_pipeline import PipelineMainFile
-
- await database.connect()
- try:
- # Create a temporary transcript for processing
- transcript = await transcripts_controller.add(
- "",
- source_kind=SourceKind.FILE,
- source_language=source_language,
- target_language=target_language,
- )
-
- # Process the file
- pipeline = PipelineMainFile(transcript_id=transcript.id)
- await pipeline.process(Path(filename))
-
- logger.info("File pipeline processing complete")
-
- finally:
- await database.disconnect()
- except ImportError as e:
- logger.error(f"File pipeline not available: {e}")
- logger.info("Falling back to stream pipeline")
- # Fall back to stream pipeline
- await process_audio_file(
- filename,
- event_callback,
- only_transcript=False,
- source_language=source_language,
- target_language=target_language,
- enable_diarization=enable_diarization,
- diarization_backend=diarization_backend,
+ transcript_id = await prepare_entry(
+ source_path,
+ source_language,
+ target_language,
)
+ pipeline_handlers = {
+ "live": process_live_pipeline,
+ "file": process_file_pipeline,
+ }
+
+ handler = pipeline_handlers.get(pipeline)
+ if not handler:
+ raise ValueError(f"Unknown pipeline type: {pipeline}")
+
+ await handler(transcript_id)
+
+ await extract_result_from_entry(transcript_id, output_path)
+ finally:
+ await database.disconnect()
+
if __name__ == "__main__":
- import argparse
- import os
-
parser = argparse.ArgumentParser(
- description="Process audio files with optional speaker diarization"
+ description="Process audio files with speaker diarization"
)
parser.add_argument("source", help="Source file (mp3, wav, mp4...)")
parser.add_argument(
- "--stream",
- action="store_true",
- help="Use streaming pipeline (original frame-based processing)",
- )
- parser.add_argument(
- "--only-transcript",
- "-t",
- action="store_true",
- help="Only generate transcript without topics/summaries",
+ "--pipeline",
+ required=True,
+ choices=["live", "file"],
+ help="Pipeline type to use for processing (live: streaming/incremental, file: batch/parallel)",
)
parser.add_argument(
"--source-language", default="en", help="Source language code (default: en)"
@@ -297,82 +207,14 @@ if __name__ == "__main__":
"--target-language", default="en", help="Target language code (default: en)"
)
parser.add_argument("--output", "-o", help="Output file (output.jsonl)")
- parser.add_argument(
- "--enable-diarization",
- "-d",
- action="store_true",
- help="Enable speaker diarization",
- )
- parser.add_argument(
- "--diarization-backend",
- default="pyannote",
- choices=["pyannote", "modal"],
- help="Diarization backend to use (default: pyannote)",
- )
args = parser.parse_args()
- if "REDIS_HOST" not in os.environ:
- os.environ["REDIS_HOST"] = "localhost"
-
- output_fd = None
- if args.output:
- output_fd = open(args.output, "w")
-
- async def event_callback(event: PipelineEvent):
- processor = event.processor
- data = event.data
-
- # Ignore internal processors
- if processor in (
- "AudioDownscaleProcessor",
- "AudioChunkerAutoProcessor",
- "AudioMergeProcessor",
- "AudioFileWriterProcessor",
- "TopicCollectorProcessor",
- "BroadcastProcessor",
- ):
- return
-
- # If diarization is enabled, skip the original topic events from the pipeline
- # The diarization processor will emit the same topics but with speaker info
- if processor == "TranscriptTopicDetectorProcessor" and args.enable_diarization:
- return
-
- # Log all events
- logger.info(f"Event: {processor} - {type(data).__name__}")
-
- # Write to output
- if output_fd:
- output_fd.write(event.model_dump_json())
- output_fd.write("\n")
- output_fd.flush()
-
- if args.stream:
- # Use original streaming pipeline
- asyncio.run(
- process_audio_file(
- args.source,
- event_callback,
- only_transcript=args.only_transcript,
- source_language=args.source_language,
- target_language=args.target_language,
- enable_diarization=args.enable_diarization,
- diarization_backend=args.diarization_backend,
- )
+ asyncio.run(
+ process(
+ args.source,
+ args.source_language,
+ args.target_language,
+ args.pipeline,
+ args.output,
)
- else:
- # Use optimized file pipeline (default)
- asyncio.run(
- process_file_pipeline(
- args.source,
- event_callback,
- source_language=args.source_language,
- target_language=args.target_language,
- enable_diarization=args.enable_diarization,
- diarization_backend=args.diarization_backend,
- )
- )
-
- if output_fd:
- output_fd.close()
- logger.info(f"Output written to {args.output}")
+ )
diff --git a/server/reflector/tools/process_with_diarization.py b/server/reflector/tools/process_with_diarization.py
deleted file mode 100644
index f1415e1a..00000000
--- a/server/reflector/tools/process_with_diarization.py
+++ /dev/null
@@ -1,318 +0,0 @@
-"""
-@vibe-generated
-Process audio file with diarization support
-===========================================
-
-Extended version of process.py that includes speaker diarization.
-This tool processes audio files locally without requiring the full server infrastructure.
-"""
-
-import asyncio
-import tempfile
-import uuid
-from pathlib import Path
-from typing import List
-
-import av
-
-from reflector.logger import logger
-from reflector.processors import (
- AudioChunkerAutoProcessor,
- AudioDownscaleProcessor,
- AudioFileWriterProcessor,
- AudioMergeProcessor,
- AudioTranscriptAutoProcessor,
- Pipeline,
- PipelineEvent,
- TranscriptFinalSummaryProcessor,
- TranscriptFinalTitleProcessor,
- TranscriptLinerProcessor,
- TranscriptTopicDetectorProcessor,
- TranscriptTranslatorAutoProcessor,
-)
-from reflector.processors.base import BroadcastProcessor, Processor
-from reflector.processors.types import (
- AudioDiarizationInput,
- TitleSummary,
- TitleSummaryWithId,
-)
-
-
-class TopicCollectorProcessor(Processor):
- """Collect topics for diarization"""
-
- INPUT_TYPE = TitleSummary
- OUTPUT_TYPE = TitleSummary
-
- def __init__(self, **kwargs):
- super().__init__(**kwargs)
- self.topics: List[TitleSummaryWithId] = []
- self._topic_id = 0
-
- async def _push(self, data: TitleSummary):
- # Convert to TitleSummaryWithId and collect
- self._topic_id += 1
- topic_with_id = TitleSummaryWithId(
- id=str(self._topic_id),
- title=data.title,
- summary=data.summary,
- timestamp=data.timestamp,
- duration=data.duration,
- transcript=data.transcript,
- )
- self.topics.append(topic_with_id)
-
- # Pass through the original topic
- await self.emit(data)
-
- def get_topics(self) -> List[TitleSummaryWithId]:
- return self.topics
-
-
-async def process_audio_file_with_diarization(
- filename,
- event_callback,
- only_transcript=False,
- source_language="en",
- target_language="en",
- enable_diarization=True,
- diarization_backend="modal",
-):
- # Create temp file for audio if diarization is enabled
- audio_temp_path = None
- if enable_diarization:
- audio_temp_file = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
- audio_temp_path = audio_temp_file.name
- audio_temp_file.close()
-
- # Create processor for collecting topics
- topic_collector = TopicCollectorProcessor()
-
- # Build pipeline for audio processing
- processors = []
-
- # Add audio file writer at the beginning if diarization is enabled
- if enable_diarization:
- processors.append(AudioFileWriterProcessor(audio_temp_path))
-
- # Add the rest of the processors
- processors += [
- AudioDownscaleProcessor(),
- AudioChunkerAutoProcessor(),
- AudioMergeProcessor(),
- AudioTranscriptAutoProcessor.as_threaded(),
- ]
-
- processors += [
- TranscriptLinerProcessor(),
- TranscriptTranslatorAutoProcessor.as_threaded(),
- ]
-
- if not only_transcript:
- processors += [
- TranscriptTopicDetectorProcessor.as_threaded(),
- # Collect topics for diarization
- topic_collector,
- BroadcastProcessor(
- processors=[
- TranscriptFinalTitleProcessor.as_threaded(),
- TranscriptFinalSummaryProcessor.as_threaded(),
- ],
- ),
- ]
-
- # Create main pipeline
- pipeline = Pipeline(*processors)
- pipeline.set_pref("audio:source_language", source_language)
- pipeline.set_pref("audio:target_language", target_language)
- pipeline.describe()
- pipeline.on(event_callback)
-
- # Start processing audio
- logger.info(f"Opening {filename}")
- container = av.open(filename)
- try:
- logger.info("Start pushing audio into the pipeline")
- for frame in container.decode(audio=0):
- await pipeline.push(frame)
- finally:
- logger.info("Flushing the pipeline")
- await pipeline.flush()
-
- # Run diarization if enabled and we have topics
- if enable_diarization and not only_transcript and audio_temp_path:
- topics = topic_collector.get_topics()
-
- if topics:
- logger.info(f"Starting diarization with {len(topics)} topics")
-
- try:
- from reflector.processors import AudioDiarizationAutoProcessor
-
- diarization_processor = AudioDiarizationAutoProcessor(
- name=diarization_backend
- )
-
- diarization_processor.set_pipeline(pipeline)
-
- # For Modal backend, we need to upload the file to S3 first
- if diarization_backend == "modal":
- from datetime import datetime, timezone
-
- from reflector.storage import get_transcripts_storage
- from reflector.utils.s3_temp_file import S3TemporaryFile
-
- storage = get_transcripts_storage()
-
- # Generate a unique filename in evaluation folder
- timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
- audio_filename = f"evaluation/diarization_temp/{timestamp}_{uuid.uuid4().hex}.wav"
-
- # Use context manager for automatic cleanup
- async with S3TemporaryFile(storage, audio_filename) as s3_file:
- # Read and upload the audio file
- with open(audio_temp_path, "rb") as f:
- audio_data = f.read()
-
- audio_url = await s3_file.upload(audio_data)
- logger.info(f"Uploaded audio to S3: {audio_filename}")
-
- # Create diarization input with S3 URL
- diarization_input = AudioDiarizationInput(
- audio_url=audio_url, topics=topics
- )
-
- # Run diarization
- await diarization_processor.push(diarization_input)
- await diarization_processor.flush()
-
- logger.info("Diarization complete")
- # File will be automatically cleaned up when exiting the context
- else:
- # For local backend, use local file path
- audio_url = audio_temp_path
-
- # Create diarization input
- diarization_input = AudioDiarizationInput(
- audio_url=audio_url, topics=topics
- )
-
- # Run diarization
- await diarization_processor.push(diarization_input)
- await diarization_processor.flush()
-
- logger.info("Diarization complete")
-
- except ImportError as e:
- logger.error(f"Failed to import diarization dependencies: {e}")
- logger.error(
- "Install with: uv pip install pyannote.audio torch torchaudio"
- )
- logger.error(
- "And set HF_TOKEN environment variable for pyannote models"
- )
- raise SystemExit(1)
- except Exception as e:
- logger.error(f"Diarization failed: {e}")
- raise SystemExit(1)
- else:
- logger.warning("Skipping diarization: no topics available")
-
- # Clean up temp file
- if audio_temp_path:
- try:
- Path(audio_temp_path).unlink()
- except Exception as e:
- logger.warning(f"Failed to clean up temp file {audio_temp_path}: {e}")
-
- logger.info("All done!")
-
-
-if __name__ == "__main__":
- import argparse
- import os
-
- parser = argparse.ArgumentParser(
- description="Process audio files with optional speaker diarization"
- )
- parser.add_argument("source", help="Source file (mp3, wav, mp4...)")
- parser.add_argument(
- "--only-transcript",
- "-t",
- action="store_true",
- help="Only generate transcript without topics/summaries",
- )
- parser.add_argument(
- "--source-language", default="en", help="Source language code (default: en)"
- )
- parser.add_argument(
- "--target-language", default="en", help="Target language code (default: en)"
- )
- parser.add_argument("--output", "-o", help="Output file (output.jsonl)")
- parser.add_argument(
- "--enable-diarization",
- "-d",
- action="store_true",
- help="Enable speaker diarization",
- )
- parser.add_argument(
- "--diarization-backend",
- default="modal",
- choices=["modal"],
- help="Diarization backend to use (default: modal)",
- )
- args = parser.parse_args()
-
- # Set REDIS_HOST to localhost if not provided
- if "REDIS_HOST" not in os.environ:
- os.environ["REDIS_HOST"] = "localhost"
- logger.info("REDIS_HOST not set, defaulting to localhost")
-
- output_fd = None
- if args.output:
- output_fd = open(args.output, "w")
-
- async def event_callback(event: PipelineEvent):
- processor = event.processor
- data = event.data
-
- # Ignore internal processors
- if processor in (
- "AudioDownscaleProcessor",
- "AudioChunkerAutoProcessor",
- "AudioMergeProcessor",
- "AudioFileWriterProcessor",
- "TopicCollectorProcessor",
- "BroadcastProcessor",
- ):
- return
-
- # If diarization is enabled, skip the original topic events from the pipeline
- # The diarization processor will emit the same topics but with speaker info
- if processor == "TranscriptTopicDetectorProcessor" and args.enable_diarization:
- return
-
- # Log all events
- logger.info(f"Event: {processor} - {type(data).__name__}")
-
- # Write to output
- if output_fd:
- output_fd.write(event.model_dump_json())
- output_fd.write("\n")
- output_fd.flush()
-
- asyncio.run(
- process_audio_file_with_diarization(
- args.source,
- event_callback,
- only_transcript=args.only_transcript,
- source_language=args.source_language,
- target_language=args.target_language,
- enable_diarization=args.enable_diarization,
- diarization_backend=args.diarization_backend,
- )
- )
-
- if output_fd:
- output_fd.close()
- logger.info(f"Output written to {args.output}")
diff --git a/server/reflector/tools/test_diarization.py b/server/reflector/tools/test_diarization.py
deleted file mode 100644
index bd071d96..00000000
--- a/server/reflector/tools/test_diarization.py
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/usr/bin/env python3
-"""
-@vibe-generated
-Test script for the diarization CLI tool
-=========================================
-
-This script helps test the diarization functionality with sample audio files.
-"""
-
-import asyncio
-import sys
-from pathlib import Path
-
-from reflector.logger import logger
-
-
-async def test_diarization(audio_file: str):
- """Test the diarization functionality"""
-
- # Import the processing function
- from process_with_diarization import process_audio_file_with_diarization
-
- # Collect events
- events = []
-
- async def event_callback(event):
- events.append({"processor": event.processor, "data": event.data})
- logger.info(f"Event from {event.processor}")
-
- # Process the audio file
- logger.info(f"Processing audio file: {audio_file}")
-
- try:
- await process_audio_file_with_diarization(
- audio_file,
- event_callback,
- only_transcript=False,
- source_language="en",
- target_language="en",
- enable_diarization=True,
- diarization_backend="modal",
- )
-
- # Analyze results
- logger.info(f"Processing complete. Received {len(events)} events")
-
- # Look for diarization results
- diarized_topics = []
- for event in events:
- if "TitleSummary" in event["processor"]:
- # Check if words have speaker information
- if hasattr(event["data"], "transcript") and event["data"].transcript:
- words = event["data"].transcript.words
- if words and hasattr(words[0], "speaker"):
- speakers = set(
- w.speaker for w in words if hasattr(w, "speaker")
- )
- logger.info(
- f"Found {len(speakers)} speakers in topic: {event['data'].title}"
- )
- diarized_topics.append(event["data"])
-
- if diarized_topics:
- logger.info(f"Successfully diarized {len(diarized_topics)} topics")
-
- # Print sample output
- sample_topic = diarized_topics[0]
- logger.info("Sample diarized output:")
- for i, word in enumerate(sample_topic.transcript.words[:10]):
- logger.info(f" Word {i}: '{word.text}' - Speaker {word.speaker}")
- else:
- logger.warning("No diarization results found in output")
-
- return events
-
- except Exception as e:
- logger.error(f"Error during processing: {e}")
- raise
-
-
-def main():
- if len(sys.argv) < 2:
- print("Usage: python test_diarization.py ")
- sys.exit(1)
-
- audio_file = sys.argv[1]
- if not Path(audio_file).exists():
- print(f"Error: Audio file '{audio_file}' not found")
- sys.exit(1)
-
- # Run the test
- asyncio.run(test_diarization(audio_file))
-
-
-if __name__ == "__main__":
- main()
diff --git a/server/tests/test_processors_pipeline.py b/server/tests/test_processors_pipeline.py
deleted file mode 100644
index 7ae22a6c..00000000
--- a/server/tests/test_processors_pipeline.py
+++ /dev/null
@@ -1,61 +0,0 @@
-import pytest
-
-
-@pytest.mark.asyncio
-@pytest.mark.parametrize("enable_diarization", [False, True])
-async def test_basic_process(
- dummy_transcript,
- dummy_llm,
- dummy_processors,
- enable_diarization,
- dummy_diarization,
-):
- # goal is to start the server, and send rtc audio to it
- # validate the events received
- from pathlib import Path
-
- from reflector.settings import settings
- from reflector.tools.process import process_audio_file
-
- # LLM_BACKEND no longer exists in settings
- # settings.LLM_BACKEND = "test"
- settings.TRANSCRIPT_BACKEND = "whisper"
-
- # event callback
- marks = {}
-
- async def event_callback(event):
- if event.processor not in marks:
- marks[event.processor] = 0
- marks[event.processor] += 1
-
- # invoke the process and capture events
- path = Path(__file__).parent / "records" / "test_mathieu_hello.wav"
-
- if enable_diarization:
- # Test with diarization - may fail if pyannote.audio is not installed
- try:
- await process_audio_file(
- path.as_posix(), event_callback, enable_diarization=True
- )
- except SystemExit:
- pytest.skip("pyannote.audio not installed - skipping diarization test")
- else:
- # Test without diarization - should always work
- await process_audio_file(
- path.as_posix(), event_callback, enable_diarization=False
- )
-
- print(f"Diarization: {enable_diarization}, Marks: {marks}")
-
- # validate the events
- # Each processor should be called for each audio segment processed
- # The final processors (Topic, Title, Summary) should be called once at the end
- assert marks["TranscriptLinerProcessor"] > 0
- assert marks["TranscriptTranslatorPassthroughProcessor"] > 0
- assert marks["TranscriptTopicDetectorProcessor"] == 1
- assert marks["TranscriptFinalSummaryProcessor"] == 1
- assert marks["TranscriptFinalTitleProcessor"] == 1
-
- if enable_diarization:
- assert marks["TestAudioDiarizationProcessor"] == 1
From f5331a210732ef9e8e449e7435f0f75407649390 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Thu, 28 Aug 2025 12:22:07 -0600
Subject: [PATCH 03/77] style: more type annotations to parakeet transcriber
(#581)
* feat: add comprehensive type annotations to Parakeet transcriber
- Add TypedDict for WordTiming with word, start, end fields
- Add NamedTuple for TimeSegment, AudioSegment, and TranscriptResult
- Add type hints to all generator functions (vad_segment_generator, batch_speech_segments, etc.)
- Add enforce_word_timing_constraints function to prevent word timing overlaps
- Refactor batch_segment_to_audio_segment to reuse pad_audio function
* doc: add note about space
---
.../reflector_transcriber_parakeet.py | 169 ++++++++++++------
1 file changed, 114 insertions(+), 55 deletions(-)
diff --git a/server/gpu/modal_deployments/reflector_transcriber_parakeet.py b/server/gpu/modal_deployments/reflector_transcriber_parakeet.py
index 3b6f6ad0..0827f0cc 100644
--- a/server/gpu/modal_deployments/reflector_transcriber_parakeet.py
+++ b/server/gpu/modal_deployments/reflector_transcriber_parakeet.py
@@ -3,7 +3,7 @@ import os
import sys
import threading
import uuid
-from typing import Generator, Mapping, NewType
+from typing import Generator, Mapping, NamedTuple, NewType, TypedDict
from urllib.parse import urlparse
import modal
@@ -22,6 +22,37 @@ VAD_CONFIG = {
ParakeetUniqFilename = NewType("ParakeetUniqFilename", str)
AudioFileExtension = NewType("AudioFileExtension", str)
+
+class TimeSegment(NamedTuple):
+ """Represents a time segment with start and end times."""
+
+ start: float
+ end: float
+
+
+class AudioSegment(NamedTuple):
+ """Represents an audio segment with timing and audio data."""
+
+ start: float
+ end: float
+ audio: any
+
+
+class TranscriptResult(NamedTuple):
+ """Represents a transcription result with text and word timings."""
+
+ text: str
+ words: list["WordTiming"]
+
+
+class WordTiming(TypedDict):
+ """Represents a word with its timing information."""
+
+ word: str
+ start: float
+ end: float
+
+
app = modal.App("reflector-transcriber-parakeet")
# Volume for caching model weights
@@ -167,12 +198,14 @@ class TranscriberParakeetLive:
(output,) = self.model.transcribe([padded_audio], timestamps=True)
text = output.text.strip()
- words = [
- {
- "word": word_info["word"] + " ",
- "start": round(word_info["start"], 2),
- "end": round(word_info["end"], 2),
- }
+ words: list[WordTiming] = [
+ WordTiming(
+ # XXX the space added here is to match the output of whisper
+ # whisper add space to each words, while parakeet don't
+ word=word_info["word"] + " ",
+ start=round(word_info["start"], 2),
+ end=round(word_info["end"], 2),
+ )
for word_info in output.timestamp["word"]
]
@@ -208,12 +241,12 @@ class TranscriberParakeetLive:
for i, (filename, output) in enumerate(zip(filenames, outputs)):
text = output.text.strip()
- words = [
- {
- "word": word_info["word"] + " ",
- "start": round(word_info["start"], 2),
- "end": round(word_info["end"], 2),
- }
+ words: list[WordTiming] = [
+ WordTiming(
+ word=word_info["word"] + " ",
+ start=round(word_info["start"], 2),
+ end=round(word_info["end"], 2),
+ )
for word_info in output.timestamp["word"]
]
@@ -270,7 +303,7 @@ class TranscriberParakeetFile:
def vad_segment_generator(
audio_array,
- ) -> Generator[tuple[float, float], None, None]:
+ ) -> Generator[TimeSegment, None, None]:
"""Generate speech segments using VAD with start/end sample indices"""
vad_iterator = VADIterator(self.vad_model, sampling_rate=SAMPLERATE)
window_size = VAD_CONFIG["window_size"]
@@ -296,14 +329,14 @@ class TranscriberParakeetFile:
start_time = start / float(SAMPLERATE)
end_time = end / float(SAMPLERATE)
- yield (start_time, end_time)
+ yield TimeSegment(start_time, end_time)
start = None
vad_iterator.reset_states()
def batch_speech_segments(
- segments: Generator[tuple[float, float], None, None], max_duration: int
- ) -> Generator[tuple[float, float], None, None]:
+ segments: Generator[TimeSegment, None, None], max_duration: int
+ ) -> Generator[TimeSegment, None, None]:
"""
Input segments:
[0-2] [3-5] [6-8] [10-11] [12-15] [17-19] [20-22]
@@ -319,7 +352,8 @@ class TranscriberParakeetFile:
batch_start_time = None
batch_end_time = None
- for start_time, end_time in segments:
+ for segment in segments:
+ start_time, end_time = segment.start, segment.end
if batch_start_time is None or batch_end_time is None:
batch_start_time = start_time
batch_end_time = end_time
@@ -331,59 +365,85 @@ class TranscriberParakeetFile:
batch_end_time = end_time
continue
- yield (batch_start_time, batch_end_time)
+ yield TimeSegment(batch_start_time, batch_end_time)
batch_start_time = start_time
batch_end_time = end_time
if batch_start_time is None or batch_end_time is None:
return
- yield (batch_start_time, batch_end_time)
+ yield TimeSegment(batch_start_time, batch_end_time)
- def batch_segment_to_audio_segment(segments, audio_array):
- for start_time, end_time in segments:
+ def batch_segment_to_audio_segment(
+ segments: Generator[TimeSegment, None, None],
+ audio_array,
+ ) -> Generator[AudioSegment, None, None]:
+ """Extract audio segments and apply padding for Parakeet compatibility.
+
+ Uses pad_audio to ensure segments are at least 0.5s long, preventing
+ Parakeet crashes. This padding may cause slight timing overlaps between
+ segments, which are corrected by enforce_word_timing_constraints.
+ """
+ for segment in segments:
+ start_time, end_time = segment.start, segment.end
start_sample = int(start_time * SAMPLERATE)
end_sample = int(end_time * SAMPLERATE)
audio_segment = audio_array[start_sample:end_sample]
- if end_time - start_time < VAD_CONFIG["silence_padding"]:
- silence_samples = int(
- (VAD_CONFIG["silence_padding"] - (end_time - start_time))
- * SAMPLERATE
- )
- padding = np.zeros(silence_samples, dtype=np.float32)
- audio_segment = np.concatenate([audio_segment, padding])
+ padded_segment = pad_audio(audio_segment, SAMPLERATE)
- yield start_time, end_time, audio_segment
+ yield AudioSegment(start_time, end_time, padded_segment)
- def transcribe_batch(model, audio_segments):
+ def transcribe_batch(model, audio_segments: list) -> list:
with NoStdStreams():
outputs = model.transcribe(audio_segments, timestamps=True)
return outputs
+ def enforce_word_timing_constraints(
+ words: list[WordTiming],
+ ) -> list[WordTiming]:
+ """Enforce that word end times don't exceed the start time of the next word.
+
+ Due to silence padding added in batch_segment_to_audio_segment for better
+ transcription accuracy, word timings from different segments may overlap.
+ This function ensures there are no overlaps by adjusting end times.
+ """
+ if len(words) <= 1:
+ return words
+
+ enforced_words = []
+ for i, word in enumerate(words):
+ enforced_word = word.copy()
+
+ if i < len(words) - 1:
+ next_start = words[i + 1]["start"]
+ if enforced_word["end"] > next_start:
+ enforced_word["end"] = next_start
+
+ enforced_words.append(enforced_word)
+
+ return enforced_words
+
def emit_results(
- results,
- segments_info,
- ):
+ results: list,
+ segments_info: list[AudioSegment],
+ ) -> Generator[TranscriptResult, None, None]:
"""Yield transcribed text and word timings from model output, adjusting timestamps to absolute positions."""
- for i, (output, (start_time, end_time, _)) in enumerate(
- zip(results, segments_info)
- ):
+ for i, (output, segment) in enumerate(zip(results, segments_info)):
+ start_time, end_time = segment.start, segment.end
text = output.text.strip()
- words = [
- {
- "word": word_info["word"] + " ",
- "start": round(
+ words: list[WordTiming] = [
+ WordTiming(
+ word=word_info["word"] + " ",
+ start=round(
word_info["start"] + start_time + timestamp_offset, 2
),
- "end": round(
- word_info["end"] + start_time + timestamp_offset, 2
- ),
- }
+ end=round(word_info["end"] + start_time + timestamp_offset, 2),
+ )
for word_info in output.timestamp["word"]
]
- yield text, words
+ yield TranscriptResult(text, words)
upload_volume.reload()
@@ -393,10 +453,9 @@ class TranscriberParakeetFile:
audio_array = load_and_convert_audio(file_path)
total_duration = len(audio_array) / float(SAMPLERATE)
- processed_duration = 0.0
- all_text_parts = []
- all_words = []
+ all_text_parts: list[str] = []
+ all_words: list[WordTiming] = []
raw_segments = vad_segment_generator(audio_array)
speech_segments = batch_speech_segments(
@@ -406,19 +465,19 @@ class TranscriberParakeetFile:
audio_segments = batch_segment_to_audio_segment(speech_segments, audio_array)
for batch in audio_segments:
- _, _, audio_segment = batch
+ audio_segment = batch.audio
results = transcribe_batch(self.model, [audio_segment])
- for text, words in emit_results(
+ for result in emit_results(
results,
[batch],
):
- if not text:
+ if not result.text:
continue
- all_text_parts.append(text)
- all_words.extend(words)
+ all_text_parts.append(result.text)
+ all_words.extend(result.words)
- processed_duration += len(audio_segment) / float(SAMPLERATE)
+ all_words = enforce_word_timing_constraints(all_words)
combined_text = " ".join(all_text_parts)
return {"text": combined_text, "words": all_words}
From 55cc8637c6d3668f2a9c460b23f4fea295ea0904 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Thu, 28 Aug 2025 16:43:17 -0600
Subject: [PATCH 04/77] ci: restrict workflow execution to main branch and add
concurrency (#586)
* ci: try adding concurrency
* ci: restrict push on main branch
* ci: fix concurrency key
* ci: fix build concurrency
* refactor: apply suggestion from @pr-agent-monadical[bot]
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
---------
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
---
.github/workflows/db_migrations.yml | 5 +++++
.github/workflows/test_server.yml | 11 +++++++++++
2 files changed, 16 insertions(+)
diff --git a/.github/workflows/db_migrations.yml b/.github/workflows/db_migrations.yml
index ff8ad59a..2b80c3a1 100644
--- a/.github/workflows/db_migrations.yml
+++ b/.github/workflows/db_migrations.yml
@@ -2,6 +2,8 @@ name: Test Database Migrations
on:
push:
+ branches:
+ - main
paths:
- "server/migrations/**"
- "server/reflector/db/**"
@@ -17,6 +19,9 @@ on:
jobs:
test-migrations:
runs-on: ubuntu-latest
+ concurrency:
+ group: db-ubuntu-latest-${{ github.ref }}
+ cancel-in-progress: true
services:
postgres:
image: postgres:17
diff --git a/.github/workflows/test_server.yml b/.github/workflows/test_server.yml
index 262e0e05..f03d020e 100644
--- a/.github/workflows/test_server.yml
+++ b/.github/workflows/test_server.yml
@@ -5,12 +5,17 @@ on:
paths:
- "server/**"
push:
+ branches:
+ - main
paths:
- "server/**"
jobs:
pytest:
runs-on: ubuntu-latest
+ concurrency:
+ group: pytest-${{ github.ref }}
+ cancel-in-progress: true
services:
redis:
image: redis:6
@@ -30,6 +35,9 @@ jobs:
docker-amd64:
runs-on: linux-amd64
+ concurrency:
+ group: docker-amd64-${{ github.ref }}
+ cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
@@ -45,6 +53,9 @@ jobs:
docker-arm64:
runs-on: linux-arm64
+ concurrency:
+ group: docker-arm64-${{ github.ref }}
+ cancel-in-progress: true
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
From 9dfd76996f851cc52be54feea078adbc0816dc57 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Fri, 29 Aug 2025 00:58:14 -0600
Subject: [PATCH 05/77] fix: file pipeline status reporting and websocket
updates (#589)
* feat: use file pipeline for upload and reprocess action
* fix: make file pipeline correctly report status events
* fix: duplication of transcripts_controller
* fix: tests
* test: fix file upload test
* test: fix reprocess
* fix: also patch from main_file_pipeline
(how patch is done is dependent of file import unfortunately)
---
server/reflector/db/transcripts.py | 33 ++++++++-
.../reflector/pipelines/main_file_pipeline.py | 51 +++++++++++---
.../reflector/pipelines/main_live_pipeline.py | 32 ++++-----
server/reflector/views/transcripts_process.py | 4 +-
server/reflector/views/transcripts_upload.py | 4 +-
server/tests/conftest.py | 68 ++++++++++++++++++-
.../tests/test_transcripts_audio_download.py | 2 +-
server/tests/test_transcripts_process.py | 15 ++--
server/tests/test_transcripts_upload.py | 11 +--
9 files changed, 170 insertions(+), 50 deletions(-)
diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py
index 9dbcba9f..47148995 100644
--- a/server/reflector/db/transcripts.py
+++ b/server/reflector/db/transcripts.py
@@ -122,6 +122,15 @@ def generate_transcript_name() -> str:
return f"Transcript {now.strftime('%Y-%m-%d %H:%M:%S')}"
+TranscriptStatus = Literal[
+ "idle", "uploaded", "recording", "processing", "error", "ended"
+]
+
+
+class StrValue(BaseModel):
+ value: str
+
+
class AudioWaveform(BaseModel):
data: list[float]
@@ -185,7 +194,7 @@ class Transcript(BaseModel):
id: str = Field(default_factory=generate_uuid4)
user_id: str | None = None
name: str = Field(default_factory=generate_transcript_name)
- status: str = "idle"
+ status: TranscriptStatus = "idle"
duration: float = 0
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
title: str | None = None
@@ -732,5 +741,27 @@ class TranscriptController:
transcript.delete_participant(participant_id)
await self.update(transcript, {"participants": transcript.participants_dump()})
+ async def set_status(
+ self, transcript_id: str, status: TranscriptStatus
+ ) -> TranscriptEvent | None:
+ """
+ Update the status of a transcript
+
+ Will add an event STATUS + update the status field of transcript
+ """
+ async with self.transaction():
+ transcript = await self.get_by_id(transcript_id)
+ if not transcript:
+ raise Exception(f"Transcript {transcript_id} not found")
+ if transcript.status == status:
+ return
+ resp = await self.append_event(
+ transcript=transcript,
+ event="STATUS",
+ data=StrValue(value=status),
+ )
+ await self.update(transcript, {"status": status})
+ return resp
+
transcripts_controller = TranscriptController()
diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py
index f2c8fb85..f11cddca 100644
--- a/server/reflector/pipelines/main_file_pipeline.py
+++ b/server/reflector/pipelines/main_file_pipeline.py
@@ -15,10 +15,15 @@ from celery import shared_task
from reflector.db.transcripts import (
Transcript,
+ TranscriptStatus,
transcripts_controller,
)
from reflector.logger import logger
-from reflector.pipelines.main_live_pipeline import PipelineMainBase, asynctask
+from reflector.pipelines.main_live_pipeline import (
+ PipelineMainBase,
+ asynctask,
+ broadcast_to_sockets,
+)
from reflector.processors import (
AudioFileWriterProcessor,
TranscriptFinalSummaryProcessor,
@@ -83,12 +88,27 @@ class PipelineMainFile(PipelineMainBase):
exc_info=result,
)
+ @broadcast_to_sockets
+ async def set_status(self, transcript_id: str, status: TranscriptStatus):
+ async with self.lock_transaction():
+ return await transcripts_controller.set_status(transcript_id, status)
+
async def process(self, file_path: Path):
"""Main entry point for file processing"""
self.logger.info(f"Starting file pipeline for {file_path}")
transcript = await self.get_transcript()
+ # Clear transcript as we're going to regenerate everything
+ async with self.transaction():
+ await transcripts_controller.update(
+ transcript,
+ {
+ "events": [],
+ "topics": [],
+ },
+ )
+
# Extract audio and write to transcript location
audio_path = await self.extract_and_write_audio(file_path, transcript)
@@ -105,6 +125,8 @@ class PipelineMainFile(PipelineMainBase):
self.logger.info("File pipeline complete")
+ await transcripts_controller.set_status(transcript.id, "ended")
+
async def extract_and_write_audio(
self, file_path: Path, transcript: Transcript
) -> Path:
@@ -362,14 +384,21 @@ async def task_pipeline_file_process(*, transcript_id: str):
if not transcript:
raise Exception(f"Transcript {transcript_id} not found")
- # Find the file to process
- audio_file = next(transcript.data_path.glob("upload.*"), None)
- if not audio_file:
- audio_file = next(transcript.data_path.glob("audio.*"), None)
-
- if not audio_file:
- raise Exception("No audio file found to process")
-
- # Run file pipeline
pipeline = PipelineMainFile(transcript_id=transcript_id)
- await pipeline.process(audio_file)
+
+ try:
+ await pipeline.set_status(transcript_id, "processing")
+
+ # Find the file to process
+ audio_file = next(transcript.data_path.glob("upload.*"), None)
+ if not audio_file:
+ audio_file = next(transcript.data_path.glob("audio.*"), None)
+
+ if not audio_file:
+ raise Exception("No audio file found to process")
+
+ await pipeline.process(audio_file)
+
+ except Exception:
+ await pipeline.set_status(transcript_id, "error")
+ raise
diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py
index 812847db..30c8777b 100644
--- a/server/reflector/pipelines/main_live_pipeline.py
+++ b/server/reflector/pipelines/main_live_pipeline.py
@@ -32,6 +32,7 @@ from reflector.db.transcripts import (
TranscriptFinalLongSummary,
TranscriptFinalShortSummary,
TranscriptFinalTitle,
+ TranscriptStatus,
TranscriptText,
TranscriptTopic,
TranscriptWaveform,
@@ -188,8 +189,15 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage]
]
@asynccontextmanager
- async def transaction(self):
+ async def lock_transaction(self):
+ # This lock is to prevent multiple processor starting adding
+ # into event array at the same time
async with self._lock:
+ yield
+
+ @asynccontextmanager
+ async def transaction(self):
+ async with self.lock_transaction():
async with transcripts_controller.transaction():
yield
@@ -198,14 +206,14 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage]
# if it's the first part, update the status of the transcript
# but do not set the ended status yet.
if isinstance(self, PipelineMainLive):
- status_mapping = {
+ status_mapping: dict[str, TranscriptStatus] = {
"started": "recording",
"push": "recording",
"flush": "processing",
"error": "error",
}
elif isinstance(self, PipelineMainFinalSummaries):
- status_mapping = {
+ status_mapping: dict[str, TranscriptStatus] = {
"push": "processing",
"flush": "processing",
"error": "error",
@@ -221,22 +229,8 @@ class PipelineMainBase(PipelineRunner[PipelineMessage], Generic[PipelineMessage]
return
# when the status of the pipeline changes, update the transcript
- async with self.transaction():
- transcript = await self.get_transcript()
- if status == transcript.status:
- return
- resp = await transcripts_controller.append_event(
- transcript=transcript,
- event="STATUS",
- data=StrValue(value=status),
- )
- await transcripts_controller.update(
- transcript,
- {
- "status": status,
- },
- )
- return resp
+ async with self._lock:
+ return await transcripts_controller.set_status(self.transcript_id, status)
@broadcast_to_sockets
async def on_transcript(self, data):
diff --git a/server/reflector/views/transcripts_process.py b/server/reflector/views/transcripts_process.py
index 8f6d3ab6..0200e7f8 100644
--- a/server/reflector/views/transcripts_process.py
+++ b/server/reflector/views/transcripts_process.py
@@ -6,7 +6,7 @@ from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.transcripts import transcripts_controller
-from reflector.pipelines.main_live_pipeline import task_pipeline_process
+from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
router = APIRouter()
@@ -40,7 +40,7 @@ async def transcript_process(
return ProcessStatus(status="already running")
# schedule a background task process the file
- task_pipeline_process.delay(transcript_id=transcript_id)
+ task_pipeline_file_process.delay(transcript_id=transcript_id)
return ProcessStatus(status="ok")
diff --git a/server/reflector/views/transcripts_upload.py b/server/reflector/views/transcripts_upload.py
index 18e75dac..8efbc274 100644
--- a/server/reflector/views/transcripts_upload.py
+++ b/server/reflector/views/transcripts_upload.py
@@ -6,7 +6,7 @@ from pydantic import BaseModel
import reflector.auth as auth
from reflector.db.transcripts import transcripts_controller
-from reflector.pipelines.main_live_pipeline import task_pipeline_process
+from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
router = APIRouter()
@@ -92,6 +92,6 @@ async def transcript_record_upload(
await transcripts_controller.update(transcript, {"status": "uploaded"})
# launch a background task to process the file
- task_pipeline_process.delay(transcript_id=transcript_id)
+ task_pipeline_file_process.delay(transcript_id=transcript_id)
return UploadStatus(status="ok")
diff --git a/server/tests/conftest.py b/server/tests/conftest.py
index d739751d..22fe4193 100644
--- a/server/tests/conftest.py
+++ b/server/tests/conftest.py
@@ -178,6 +178,63 @@ async def dummy_diarization():
yield
+@pytest.fixture
+async def dummy_file_transcript():
+ from reflector.processors.file_transcript import FileTranscriptProcessor
+ from reflector.processors.types import Transcript, Word
+
+ class TestFileTranscriptProcessor(FileTranscriptProcessor):
+ async def _transcript(self, data):
+ return Transcript(
+ text="Hello world. How are you today?",
+ words=[
+ Word(start=0.0, end=0.5, text="Hello", speaker=0),
+ Word(start=0.5, end=0.6, text=" ", speaker=0),
+ Word(start=0.6, end=1.0, text="world", speaker=0),
+ Word(start=1.0, end=1.1, text=".", speaker=0),
+ Word(start=1.1, end=1.2, text=" ", speaker=0),
+ Word(start=1.2, end=1.5, text="How", speaker=0),
+ Word(start=1.5, end=1.6, text=" ", speaker=0),
+ Word(start=1.6, end=1.8, text="are", speaker=0),
+ Word(start=1.8, end=1.9, text=" ", speaker=0),
+ Word(start=1.9, end=2.1, text="you", speaker=0),
+ Word(start=2.1, end=2.2, text=" ", speaker=0),
+ Word(start=2.2, end=2.5, text="today", speaker=0),
+ Word(start=2.5, end=2.6, text="?", speaker=0),
+ ],
+ )
+
+ with patch(
+ "reflector.processors.file_transcript_auto.FileTranscriptAutoProcessor.__new__"
+ ) as mock_auto:
+ mock_auto.return_value = TestFileTranscriptProcessor()
+ yield
+
+
+@pytest.fixture
+async def dummy_file_diarization():
+ from reflector.processors.file_diarization import (
+ FileDiarizationOutput,
+ FileDiarizationProcessor,
+ )
+ from reflector.processors.types import DiarizationSegment
+
+ class TestFileDiarizationProcessor(FileDiarizationProcessor):
+ async def _diarize(self, data):
+ return FileDiarizationOutput(
+ diarization=[
+ DiarizationSegment(start=0.0, end=1.1, speaker=0),
+ DiarizationSegment(start=1.2, end=2.6, speaker=1),
+ ]
+ )
+
+ with patch(
+ "reflector.processors.file_diarization_auto.FileDiarizationAutoProcessor.__new__"
+ ) as mock_auto:
+ mock_auto.return_value = TestFileDiarizationProcessor()
+ yield
+
+
@pytest.fixture
async def dummy_transcript_translator():
from reflector.processors.transcript_translator import TranscriptTranslatorProcessor
@@ -238,9 +295,13 @@ async def dummy_storage():
with (
patch("reflector.storage.base.Storage.get_instance") as mock_storage,
patch("reflector.storage.get_transcripts_storage") as mock_get_transcripts,
+ patch(
+ "reflector.pipelines.main_file_pipeline.get_transcripts_storage"
+ ) as mock_get_transcripts2,
):
mock_storage.return_value = dummy
mock_get_transcripts.return_value = dummy
+ mock_get_transcripts2.return_value = dummy
yield
@@ -260,7 +321,10 @@ def celery_config():
@pytest.fixture(scope="session")
def celery_includes():
- return ["reflector.pipelines.main_live_pipeline"]
+ return [
+ "reflector.pipelines.main_live_pipeline",
+ "reflector.pipelines.main_file_pipeline",
+ ]
@pytest.fixture
@@ -302,7 +366,7 @@ async def fake_transcript_with_topics(tmpdir, client):
transcript = await transcripts_controller.get_by_id(tid)
assert transcript is not None
- await transcripts_controller.update(transcript, {"status": "finished"})
+ await transcripts_controller.update(transcript, {"status": "ended"})
# manually copy a file at the expected location
audio_filename = transcript.audio_mp3_filename
diff --git a/server/tests/test_transcripts_audio_download.py b/server/tests/test_transcripts_audio_download.py
index 81b74def..e40d0ade 100644
--- a/server/tests/test_transcripts_audio_download.py
+++ b/server/tests/test_transcripts_audio_download.py
@@ -19,7 +19,7 @@ async def fake_transcript(tmpdir, client):
transcript = await transcripts_controller.get_by_id(tid)
assert transcript is not None
- await transcripts_controller.update(transcript, {"status": "finished"})
+ await transcripts_controller.update(transcript, {"status": "ended"})
# manually copy a file at the expected location
audio_filename = transcript.audio_mp3_filename
diff --git a/server/tests/test_transcripts_process.py b/server/tests/test_transcripts_process.py
index 3551d718..5f45cf4b 100644
--- a/server/tests/test_transcripts_process.py
+++ b/server/tests/test_transcripts_process.py
@@ -29,10 +29,10 @@ async def client(app_lifespan):
@pytest.mark.asyncio
async def test_transcript_process(
tmpdir,
- whisper_transcript,
dummy_llm,
dummy_processors,
- dummy_diarization,
+ dummy_file_transcript,
+ dummy_file_diarization,
dummy_storage,
client,
):
@@ -56,8 +56,8 @@ async def test_transcript_process(
assert response.status_code == 200
assert response.json()["status"] == "ok"
- # wait for processing to finish (max 10 minutes)
- timeout_seconds = 600 # 10 minutes
+ # wait for processing to finish (max 1 minute)
+ timeout_seconds = 60
start_time = time.monotonic()
while (time.monotonic() - start_time) < timeout_seconds:
# fetch the transcript and check if it is ended
@@ -75,9 +75,10 @@ async def test_transcript_process(
)
assert response.status_code == 200
assert response.json()["status"] == "ok"
+ await asyncio.sleep(2)
- # wait for processing to finish (max 10 minutes)
- timeout_seconds = 600 # 10 minutes
+ # wait for processing to finish (max 1 minute)
+ timeout_seconds = 60
start_time = time.monotonic()
while (time.monotonic() - start_time) < timeout_seconds:
# fetch the transcript and check if it is ended
@@ -99,4 +100,4 @@ async def test_transcript_process(
response = await client.get(f"/transcripts/{tid}/topics")
assert response.status_code == 200
assert len(response.json()) == 1
- assert "want to share" in response.json()[0]["transcript"]
+ assert "Hello world. How are you today?" in response.json()[0]["transcript"]
diff --git a/server/tests/test_transcripts_upload.py b/server/tests/test_transcripts_upload.py
index ee08b1be..e9a90c7a 100644
--- a/server/tests/test_transcripts_upload.py
+++ b/server/tests/test_transcripts_upload.py
@@ -12,7 +12,8 @@ async def test_transcript_upload_file(
tmpdir,
dummy_llm,
dummy_processors,
- dummy_diarization,
+ dummy_file_transcript,
+ dummy_file_diarization,
dummy_storage,
client,
):
@@ -36,8 +37,8 @@ async def test_transcript_upload_file(
assert response.status_code == 200
assert response.json()["status"] == "ok"
- # wait the processing to finish (max 10 minutes)
- timeout_seconds = 600 # 10 minutes
+ # wait the processing to finish (max 1 minute)
+ timeout_seconds = 60
start_time = time.monotonic()
while (time.monotonic() - start_time) < timeout_seconds:
# fetch the transcript and check if it is ended
@@ -47,7 +48,7 @@ async def test_transcript_upload_file(
break
await asyncio.sleep(1)
else:
- pytest.fail(f"Processing timed out after {timeout_seconds} seconds")
+ return pytest.fail(f"Processing timed out after {timeout_seconds} seconds")
# check the transcript is ended
transcript = resp.json()
@@ -59,4 +60,4 @@ async def test_transcript_upload_file(
response = await client.get(f"/transcripts/{tid}/topics")
assert response.status_code == 200
assert len(response.json()) == 1
- assert "want to share" in response.json()[0]["transcript"]
+ assert "Hello world. How are you today?" in response.json()[0]["transcript"]
From 6f0c7c1a5e751713366886c8e764c2009e12ba72 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Fri, 29 Aug 2025 08:47:14 -0600
Subject: [PATCH 06/77] feat(cleanup): add automatic data retention for public
instances (#574)
* feat(cleanup): add automatic data retention for public instances
- Add Celery task to clean up anonymous data after configurable retention period
- Delete transcripts, meetings, and orphaned recordings older than retention days
- Only runs when PUBLIC_MODE is enabled to prevent accidental data loss
- Properly removes all associated files (local and S3 storage)
- Add manual cleanup tool for testing and intervention
- Configure retention via PUBLIC_DATA_RETENTION_DAYS setting (default: 7 days)
Fixes #571
* fix: apply pre-commit formatting fixes
* fix: properly delete recording files from storage during cleanup
- Add storage deletion for orphaned recordings in both cleanup task and manual tool
- Delete from storage before removing database records
- Log warnings if storage deletion fails but continue with database cleanup
* Apply suggestion from @pr-agent-monadical[bot]
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
* Apply suggestion from @pr-agent-monadical[bot]
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
* refactor: cleanup_old_data for better logging
* fix: linting
* test: fix meeting cleanup test to not require room controller
- Simplify test by directly inserting meetings into database
- Remove dependency on non-existent rooms_controller.create method
- Tests now pass successfully
* fix: linting
* refactor: simplify cleanup tool to use worker implementation
- Remove duplicate cleanup logic from manual tool
- Use the same _cleanup_old_public_data function from worker
- Remove dry-run feature as requested
- Prevent code duplication and ensure consistency
- Update documentation to reflect changes
* refactor: split cleanup worker into smaller functions
- Move all imports to the top of the file
- Extract cleanup logic into separate functions:
- cleanup_old_transcripts()
- cleanup_old_meetings()
- cleanup_orphaned_recordings()
- log_cleanup_results()
- Make code more maintainable and testable
- Add days parameter support to Celery task
- Update manual tool to work with refactored code
* feat: add TypedDict typing for cleanup stats
- Add CleanupStats TypedDict for better type safety
- Update all function signatures to use proper typing
- Add return type annotations to _cleanup_old_public_data
- Improves code maintainability and IDE support
* feat: add CASCADE DELETE to meeting_consent foreign key
- Add ondelete="CASCADE" to meeting_consent.meeting_id foreign key
- Generate and apply migration to update existing constraint
- Remove manual consent deletion from cleanup code
- Add unit test to verify CASCADE DELETE behavior
* style: linting
* fix: alembic migration branchpoint
* fix: correct downgrade constraint name in CASCADE DELETE migration
* fix: regenerate CASCADE DELETE migration with proper constraint names
- Delete problematic migration and regenerate with correct names
- Use explicit constraint name in both upgrade and downgrade
- Ensure migration works bidirectionally
- All tests passing including CASCADE DELETE test
* style: linting
* refactor: simplify cleanup to use transcripts as entry point
- Remove orphaned_recordings cleanup (not part of this PR scope)
- Remove separate old_meetings cleanup
- Transcripts are now the main entry point for cleanup
- Associated meetings and recordings are deleted with their transcript
- Use single database connection for all operations
- Update tests to reflect new approach
* refactor: cleanup and rename functions for clarity
- Rename _cleanup_old_public_data to cleanup_old_public_data (make public)
- Rename celery task to cleanup_old_public_data_task for clarity
- Update docstrings and improve code organization
- Remove unnecessary comments and simplify deletion logic
- Update tests to use new function names
- All tests passing
* style: linting\
* style: typing and review
* fix: add transaction on cleanup_single_transcript
* fix: naming
---------
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
---
server/docs/data_retention.md | 95 ++++++
..._add_cascade_delete_to_meeting_consent_.py | 50 +++
server/reflector/asynctask.py | 27 ++
server/reflector/db/meetings.py | 7 +-
.../reflector/pipelines/main_file_pipeline.py | 2 +-
.../reflector/pipelines/main_live_pipeline.py | 25 +-
server/reflector/settings.py | 4 +-
server/reflector/tools/cleanup_old_data.py | 72 +++++
server/reflector/worker/app.py | 11 +
server/reflector/worker/cleanup.py | 156 ++++++++++
server/tests/test_cleanup.py | 287 ++++++++++++++++++
11 files changed, 708 insertions(+), 28 deletions(-)
create mode 100644 server/docs/data_retention.md
create mode 100644 server/migrations/versions/5a8907fd1d78_add_cascade_delete_to_meeting_consent_.py
create mode 100644 server/reflector/asynctask.py
create mode 100644 server/reflector/tools/cleanup_old_data.py
create mode 100644 server/reflector/worker/cleanup.py
create mode 100644 server/tests/test_cleanup.py
diff --git a/server/docs/data_retention.md b/server/docs/data_retention.md
new file mode 100644
index 00000000..1a21b59d
--- /dev/null
+++ b/server/docs/data_retention.md
@@ -0,0 +1,95 @@
+# Data Retention and Cleanup
+
+## Overview
+
+For public instances of Reflector, a data retention policy is automatically enforced to delete anonymous user data after a configurable period (default: 7 days). This ensures compliance with privacy expectations and prevents unbounded storage growth.
+
+## Configuration
+
+### Environment Variables
+
+- `PUBLIC_MODE` (bool): Must be set to `true` to enable automatic cleanup
+- `PUBLIC_DATA_RETENTION_DAYS` (int): Number of days to retain anonymous data (default: 7)
+
+### What Gets Deleted
+
+When data reaches the retention period, the following items are automatically removed:
+
+1. **Transcripts** from anonymous users (where `user_id` is NULL):
+ - Database records
+ - Local files (audio.wav, audio.mp3, audio.json waveform)
+ - Storage files (cloud storage if configured)
+
+## Automatic Cleanup
+
+### Celery Beat Schedule
+
+When `PUBLIC_MODE=true`, a Celery beat task runs daily at 3 AM to clean up old data:
+
+```python
+# Automatically scheduled when PUBLIC_MODE=true
+"cleanup_old_public_data": {
+ "task": "reflector.worker.cleanup.cleanup_old_public_data",
+ "schedule": crontab(hour=3, minute=0), # Daily at 3 AM
+}
+```
+
+### Running the Worker
+
+Ensure both Celery worker and beat scheduler are running:
+
+```bash
+# Start Celery worker
+uv run celery -A reflector.worker.app worker --loglevel=info
+
+# Start Celery beat scheduler (in another terminal)
+uv run celery -A reflector.worker.app beat
+```
+
+## Manual Cleanup
+
+For testing or manual intervention, use the cleanup tool:
+
+```bash
+# Delete data older than 7 days (default)
+uv run python -m reflector.tools.cleanup_old_data
+
+# Delete data older than 30 days
+uv run python -m reflector.tools.cleanup_old_data --days 30
+```
+
+Note: The manual tool uses the same implementation as the Celery worker task to ensure consistency.
+
+## Important Notes
+
+1. **User Data Deletion**: Only anonymous data (where `user_id` is NULL) is deleted. Authenticated user data is preserved.
+
+2. **Storage Cleanup**: The system properly cleans up both local files and cloud storage when configured.
+
+3. **Error Handling**: If individual deletions fail, the cleanup continues and logs errors. Failed deletions are reported in the task output.
+
+4. **Public Instance Only**: The automatic cleanup task only runs when `PUBLIC_MODE=true` to prevent accidental data loss in private deployments.
+
+## Testing
+
+Run the cleanup tests:
+
+```bash
+uv run pytest tests/test_cleanup.py -v
+```
+
+## Monitoring
+
+Check Celery logs for cleanup task execution:
+
+```bash
+# Look for cleanup task logs
+grep "cleanup_old_public_data" celery.log
+grep "Starting cleanup of old public data" celery.log
+```
+
+Task statistics are logged after each run:
+- Number of transcripts deleted
+- Number of meetings deleted
+- Number of orphaned recordings deleted
+- Any errors encountered
diff --git a/server/migrations/versions/5a8907fd1d78_add_cascade_delete_to_meeting_consent_.py b/server/migrations/versions/5a8907fd1d78_add_cascade_delete_to_meeting_consent_.py
new file mode 100644
index 00000000..af6a5c22
--- /dev/null
+++ b/server/migrations/versions/5a8907fd1d78_add_cascade_delete_to_meeting_consent_.py
@@ -0,0 +1,50 @@
+"""add cascade delete to meeting consent foreign key
+
+Revision ID: 5a8907fd1d78
+Revises: 0ab2d7ffaa16
+Create Date: 2025-08-26 17:26:50.945491
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "5a8907fd1d78"
+down_revision: Union[str, None] = "0ab2d7ffaa16"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("meeting_consent", schema=None) as batch_op:
+ batch_op.drop_constraint(
+ batch_op.f("meeting_consent_meeting_id_fkey"), type_="foreignkey"
+ )
+ batch_op.create_foreign_key(
+ batch_op.f("meeting_consent_meeting_id_fkey"),
+ "meeting",
+ ["meeting_id"],
+ ["id"],
+ ondelete="CASCADE",
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("meeting_consent", schema=None) as batch_op:
+ batch_op.drop_constraint(
+ batch_op.f("meeting_consent_meeting_id_fkey"), type_="foreignkey"
+ )
+ batch_op.create_foreign_key(
+ batch_op.f("meeting_consent_meeting_id_fkey"),
+ "meeting",
+ ["meeting_id"],
+ ["id"],
+ )
+
+ # ### end Alembic commands ###
diff --git a/server/reflector/asynctask.py b/server/reflector/asynctask.py
new file mode 100644
index 00000000..61523a6f
--- /dev/null
+++ b/server/reflector/asynctask.py
@@ -0,0 +1,27 @@
+import asyncio
+import functools
+
+from reflector.db import get_database
+
+
+def asynctask(f):
+ @functools.wraps(f)
+ def wrapper(*args, **kwargs):
+ async def run_with_db():
+ database = get_database()
+ await database.connect()
+ try:
+ return await f(*args, **kwargs)
+ finally:
+ await database.disconnect()
+
+ coro = run_with_db()
+ try:
+ loop = asyncio.get_running_loop()
+ except RuntimeError:
+ loop = None
+ if loop and loop.is_running():
+ return loop.run_until_complete(coro)
+ return asyncio.run(coro)
+
+ return wrapper
diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py
index 40bd6f8a..85178351 100644
--- a/server/reflector/db/meetings.py
+++ b/server/reflector/db/meetings.py
@@ -54,7 +54,12 @@ meeting_consent = sa.Table(
"meeting_consent",
metadata,
sa.Column("id", sa.String, primary_key=True),
- sa.Column("meeting_id", sa.String, sa.ForeignKey("meeting.id"), nullable=False),
+ sa.Column(
+ "meeting_id",
+ sa.String,
+ sa.ForeignKey("meeting.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
sa.Column("user_id", sa.String),
sa.Column("consent_given", sa.Boolean, nullable=False),
sa.Column("consent_timestamp", sa.DateTime(timezone=True), nullable=False),
diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py
index f11cddca..42333aa9 100644
--- a/server/reflector/pipelines/main_file_pipeline.py
+++ b/server/reflector/pipelines/main_file_pipeline.py
@@ -13,6 +13,7 @@ import av
import structlog
from celery import shared_task
+from reflector.asynctask import asynctask
from reflector.db.transcripts import (
Transcript,
TranscriptStatus,
@@ -21,7 +22,6 @@ from reflector.db.transcripts import (
from reflector.logger import logger
from reflector.pipelines.main_live_pipeline import (
PipelineMainBase,
- asynctask,
broadcast_to_sockets,
)
from reflector.processors import (
diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py
index 30c8777b..64904952 100644
--- a/server/reflector/pipelines/main_live_pipeline.py
+++ b/server/reflector/pipelines/main_live_pipeline.py
@@ -22,7 +22,7 @@ from celery import chord, current_task, group, shared_task
from pydantic import BaseModel
from structlog import BoundLogger as Logger
-from reflector.db import get_database
+from reflector.asynctask import asynctask
from reflector.db.meetings import meeting_consent_controller, meetings_controller
from reflector.db.recordings import recordings_controller
from reflector.db.rooms import rooms_controller
@@ -70,29 +70,6 @@ from reflector.zulip import (
)
-def asynctask(f):
- @functools.wraps(f)
- def wrapper(*args, **kwargs):
- async def run_with_db():
- database = get_database()
- await database.connect()
- try:
- return await f(*args, **kwargs)
- finally:
- await database.disconnect()
-
- coro = run_with_db()
- try:
- loop = asyncio.get_running_loop()
- except RuntimeError:
- loop = None
- if loop and loop.is_running():
- return loop.run_until_complete(coro)
- return asyncio.run(coro)
-
- return wrapper
-
-
def broadcast_to_sockets(func):
"""
Decorator to broadcast transcript event to websockets
diff --git a/server/reflector/settings.py b/server/reflector/settings.py
index bbc835cd..686f67c1 100644
--- a/server/reflector/settings.py
+++ b/server/reflector/settings.py
@@ -1,3 +1,4 @@
+from pydantic.types import PositiveInt
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -90,9 +91,8 @@ class Settings(BaseSettings):
AUTH_JWT_PUBLIC_KEY: str | None = "authentik.monadical.com_public.pem"
AUTH_JWT_AUDIENCE: str | None = None
- # API public mode
- # if set, all anonymous record will be public
PUBLIC_MODE: bool = False
+ PUBLIC_DATA_RETENTION_DAYS: PositiveInt = 7
# Min transcript length to generate topic + summary
MIN_TRANSCRIPT_LENGTH: int = 750
diff --git a/server/reflector/tools/cleanup_old_data.py b/server/reflector/tools/cleanup_old_data.py
new file mode 100644
index 00000000..9ffa4684
--- /dev/null
+++ b/server/reflector/tools/cleanup_old_data.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python
+"""
+Manual cleanup tool for old public data.
+Uses the same implementation as the Celery worker task.
+"""
+
+import argparse
+import asyncio
+import sys
+
+import structlog
+
+from reflector.settings import settings
+from reflector.worker.cleanup import _cleanup_old_public_data
+
+logger = structlog.get_logger(__name__)
+
+
+async def cleanup_old_data(days: int = 7):
+ logger.info(
+ "Starting manual cleanup",
+ retention_days=days,
+ public_mode=settings.PUBLIC_MODE,
+ )
+
+ if not settings.PUBLIC_MODE:
+ logger.critical(
+ "WARNING: PUBLIC_MODE is False. "
+ "This tool is intended for public instances only."
+ )
+ raise Exception("Tool intended for public instances only")
+
+ result = await _cleanup_old_public_data(days=days)
+
+ if result:
+ logger.info(
+ "Cleanup completed",
+ transcripts_deleted=result.get("transcripts_deleted", 0),
+ meetings_deleted=result.get("meetings_deleted", 0),
+ recordings_deleted=result.get("recordings_deleted", 0),
+ errors_count=len(result.get("errors", [])),
+ )
+ if result.get("errors"):
+ logger.warning(
+ "Errors encountered during cleanup:", errors=result["errors"][:10]
+ )
+ else:
+ logger.info("Cleanup skipped or completed without results")
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Clean up old transcripts and meetings"
+ )
+ parser.add_argument(
+ "--days",
+ type=int,
+ default=7,
+ help="Number of days to keep data (default: 7)",
+ )
+
+ args = parser.parse_args()
+
+ if args.days < 1:
+ logger.error("Days must be at least 1")
+ sys.exit(1)
+
+ asyncio.run(cleanup_old_data(days=args.days))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/server/reflector/worker/app.py b/server/reflector/worker/app.py
index 7e888f41..e9468bd2 100644
--- a/server/reflector/worker/app.py
+++ b/server/reflector/worker/app.py
@@ -19,6 +19,7 @@ else:
"reflector.pipelines.main_live_pipeline",
"reflector.worker.healthcheck",
"reflector.worker.process",
+ "reflector.worker.cleanup",
]
)
@@ -38,6 +39,16 @@ else:
},
}
+ if settings.PUBLIC_MODE:
+ app.conf.beat_schedule["cleanup_old_public_data"] = {
+ "task": "reflector.worker.cleanup.cleanup_old_public_data_task",
+ "schedule": crontab(hour=3, minute=0),
+ }
+ logger.info(
+ "Public mode cleanup enabled",
+ retention_days=settings.PUBLIC_DATA_RETENTION_DAYS,
+ )
+
if settings.HEALTHCHECK_URL:
app.conf.beat_schedule["healthcheck_ping"] = {
"task": "reflector.worker.healthcheck.healthcheck_ping",
diff --git a/server/reflector/worker/cleanup.py b/server/reflector/worker/cleanup.py
new file mode 100644
index 00000000..e634994d
--- /dev/null
+++ b/server/reflector/worker/cleanup.py
@@ -0,0 +1,156 @@
+"""
+Main task for cleanup old public data.
+
+Deletes old anonymous transcripts and their associated meetings/recordings.
+Transcripts are the main entry point - any associated data is also removed.
+"""
+
+import asyncio
+from datetime import datetime, timedelta, timezone
+from typing import TypedDict
+
+import structlog
+from celery import shared_task
+from databases import Database
+from pydantic.types import PositiveInt
+
+from reflector.asynctask import asynctask
+from reflector.db import get_database
+from reflector.db.meetings import meetings
+from reflector.db.recordings import recordings
+from reflector.db.transcripts import transcripts, transcripts_controller
+from reflector.settings import settings
+from reflector.storage import get_recordings_storage
+
+logger = structlog.get_logger(__name__)
+
+
+class CleanupStats(TypedDict):
+ """Statistics for cleanup operation."""
+
+ transcripts_deleted: int
+ meetings_deleted: int
+ recordings_deleted: int
+ errors: list[str]
+
+
+async def delete_single_transcript(
+ db: Database, transcript_data: dict, stats: CleanupStats
+):
+ transcript_id = transcript_data["id"]
+ meeting_id = transcript_data["meeting_id"]
+ recording_id = transcript_data["recording_id"]
+
+ try:
+ async with db.transaction(isolation="serializable"):
+ if meeting_id:
+ await db.execute(meetings.delete().where(meetings.c.id == meeting_id))
+ stats["meetings_deleted"] += 1
+ logger.info("Deleted associated meeting", meeting_id=meeting_id)
+
+ if recording_id:
+ recording = await db.fetch_one(
+ recordings.select().where(recordings.c.id == recording_id)
+ )
+ if recording:
+ try:
+ await get_recordings_storage().delete_file(
+ recording["object_key"]
+ )
+ except Exception as storage_error:
+ logger.warning(
+ "Failed to delete recording from storage",
+ recording_id=recording_id,
+ object_key=recording["object_key"],
+ error=str(storage_error),
+ )
+
+ await db.execute(
+ recordings.delete().where(recordings.c.id == recording_id)
+ )
+ stats["recordings_deleted"] += 1
+ logger.info(
+ "Deleted associated recording", recording_id=recording_id
+ )
+
+ await transcripts_controller.remove_by_id(transcript_id)
+ stats["transcripts_deleted"] += 1
+ logger.info(
+ "Deleted transcript",
+ transcript_id=transcript_id,
+ created_at=transcript_data["created_at"].isoformat(),
+ )
+ except Exception as e:
+ error_msg = f"Failed to delete transcript {transcript_id}: {str(e)}"
+ logger.error(error_msg, exc_info=e)
+ stats["errors"].append(error_msg)
+
+
+async def cleanup_old_transcripts(
+ db: Database, cutoff_date: datetime, stats: CleanupStats
+):
+ """Delete old anonymous transcripts and their associated recordings/meetings."""
+ query = transcripts.select().where(
+ (transcripts.c.created_at < cutoff_date) & (transcripts.c.user_id.is_(None))
+ )
+ old_transcripts = await db.fetch_all(query)
+
+ logger.info(f"Found {len(old_transcripts)} old transcripts to delete")
+
+ for transcript_data in old_transcripts:
+ await delete_single_transcript(db, transcript_data, stats)
+
+
+def log_cleanup_results(stats: CleanupStats):
+ logger.info(
+ "Cleanup completed",
+ transcripts_deleted=stats["transcripts_deleted"],
+ meetings_deleted=stats["meetings_deleted"],
+ recordings_deleted=stats["recordings_deleted"],
+ errors_count=len(stats["errors"]),
+ )
+
+ if stats["errors"]:
+ logger.warning(
+ "Cleanup completed with errors",
+ errors=stats["errors"][:10],
+ )
+
+
+async def cleanup_old_public_data(
+ days: PositiveInt | None = None,
+) -> CleanupStats | None:
+ if days is None:
+ days = settings.PUBLIC_DATA_RETENTION_DAYS
+
+ if not settings.PUBLIC_MODE:
+ logger.info("Skipping cleanup - not a public instance")
+ return None
+
+ cutoff_date = datetime.now(timezone.utc) - timedelta(days=days)
+ logger.info(
+ "Starting cleanup of old public data",
+ cutoff_date=cutoff_date.isoformat(),
+ )
+
+ stats: CleanupStats = {
+ "transcripts_deleted": 0,
+ "meetings_deleted": 0,
+ "recordings_deleted": 0,
+ "errors": [],
+ }
+
+ db = get_database()
+ await cleanup_old_transcripts(db, cutoff_date, stats)
+
+ log_cleanup_results(stats)
+ return stats
+
+
+@shared_task(
+ autoretry_for=(Exception,),
+ retry_kwargs={"max_retries": 3, "countdown": 300},
+)
+@asynctask
+def cleanup_old_public_data_task(days: int | None = None):
+ asyncio.run(cleanup_old_public_data(days=days))
diff --git a/server/tests/test_cleanup.py b/server/tests/test_cleanup.py
new file mode 100644
index 00000000..3c5149ae
--- /dev/null
+++ b/server/tests/test_cleanup.py
@@ -0,0 +1,287 @@
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from reflector.db.recordings import Recording, recordings_controller
+from reflector.db.transcripts import SourceKind, transcripts_controller
+from reflector.worker.cleanup import cleanup_old_public_data
+
+
+@pytest.mark.asyncio
+async def test_cleanup_old_public_data_skips_when_not_public():
+ """Test that cleanup is skipped when PUBLIC_MODE is False."""
+ with patch("reflector.worker.cleanup.settings") as mock_settings:
+ mock_settings.PUBLIC_MODE = False
+
+ result = await cleanup_old_public_data()
+
+ # Should return early without doing anything
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_cleanup_old_public_data_deletes_old_anonymous_transcripts():
+ """Test that old anonymous transcripts are deleted."""
+ # Create old and new anonymous transcripts
+ old_date = datetime.now(timezone.utc) - timedelta(days=8)
+ new_date = datetime.now(timezone.utc) - timedelta(days=2)
+
+ # Create old anonymous transcript (should be deleted)
+ old_transcript = await transcripts_controller.add(
+ name="Old Anonymous Transcript",
+ source_kind=SourceKind.FILE,
+ user_id=None, # Anonymous
+ )
+ # Manually update created_at to be old
+ from reflector.db import get_database
+ from reflector.db.transcripts import transcripts
+
+ await get_database().execute(
+ transcripts.update()
+ .where(transcripts.c.id == old_transcript.id)
+ .values(created_at=old_date)
+ )
+
+ # Create new anonymous transcript (should NOT be deleted)
+ new_transcript = await transcripts_controller.add(
+ name="New Anonymous Transcript",
+ source_kind=SourceKind.FILE,
+ user_id=None, # Anonymous
+ )
+
+ # Create old transcript with user (should NOT be deleted)
+ old_user_transcript = await transcripts_controller.add(
+ name="Old User Transcript",
+ source_kind=SourceKind.FILE,
+ user_id="user123",
+ )
+ await get_database().execute(
+ transcripts.update()
+ .where(transcripts.c.id == old_user_transcript.id)
+ .values(created_at=old_date)
+ )
+
+ with patch("reflector.worker.cleanup.settings") as mock_settings:
+ mock_settings.PUBLIC_MODE = True
+ mock_settings.PUBLIC_DATA_RETENTION_DAYS = 7
+
+ # Mock the storage deletion
+ with patch("reflector.db.transcripts.get_transcripts_storage") as mock_storage:
+ mock_storage.return_value.delete_file = AsyncMock()
+
+ result = await cleanup_old_public_data()
+
+ # Check results
+ assert result["transcripts_deleted"] == 1
+ assert result["errors"] == []
+
+ # Verify old anonymous transcript was deleted
+ assert await transcripts_controller.get_by_id(old_transcript.id) is None
+
+ # Verify new anonymous transcript still exists
+ assert await transcripts_controller.get_by_id(new_transcript.id) is not None
+
+ # Verify user transcript still exists
+ assert await transcripts_controller.get_by_id(old_user_transcript.id) is not None
+
+
+@pytest.mark.asyncio
+async def test_cleanup_deletes_associated_meeting_and_recording():
+ """Test that meetings and recordings associated with old transcripts are deleted."""
+ from reflector.db import get_database
+ from reflector.db.meetings import meetings
+ from reflector.db.transcripts import transcripts
+
+ old_date = datetime.now(timezone.utc) - timedelta(days=8)
+
+ # Create a meeting
+ meeting_id = "test-meeting-for-transcript"
+ await get_database().execute(
+ meetings.insert().values(
+ id=meeting_id,
+ room_name="Meeting with Transcript",
+ room_url="https://example.com/meeting",
+ host_room_url="https://example.com/meeting-host",
+ start_date=old_date,
+ end_date=old_date + timedelta(hours=1),
+ user_id=None,
+ room_id=None,
+ )
+ )
+
+ # Create a recording
+ recording = await recordings_controller.create(
+ Recording(
+ bucket_name="test-bucket",
+ object_key="test-recording.mp4",
+ recorded_at=old_date,
+ )
+ )
+
+ # Create an old transcript with both meeting and recording
+ old_transcript = await transcripts_controller.add(
+ name="Old Transcript with Meeting and Recording",
+ source_kind=SourceKind.ROOM,
+ user_id=None,
+ meeting_id=meeting_id,
+ recording_id=recording.id,
+ )
+
+ # Update created_at to be old
+ await get_database().execute(
+ transcripts.update()
+ .where(transcripts.c.id == old_transcript.id)
+ .values(created_at=old_date)
+ )
+
+ with patch("reflector.worker.cleanup.settings") as mock_settings:
+ mock_settings.PUBLIC_MODE = True
+ mock_settings.PUBLIC_DATA_RETENTION_DAYS = 7
+
+ # Mock storage deletion
+ with patch("reflector.db.transcripts.get_transcripts_storage") as mock_storage:
+ mock_storage.return_value.delete_file = AsyncMock()
+ with patch(
+ "reflector.worker.cleanup.get_recordings_storage"
+ ) as mock_rec_storage:
+ mock_rec_storage.return_value.delete_file = AsyncMock()
+
+ result = await cleanup_old_public_data()
+
+ # Check results
+ assert result["transcripts_deleted"] == 1
+ assert result["meetings_deleted"] == 1
+ assert result["recordings_deleted"] == 1
+ assert result["errors"] == []
+
+ # Verify transcript was deleted
+ assert await transcripts_controller.get_by_id(old_transcript.id) is None
+
+ # Verify meeting was deleted
+ query = meetings.select().where(meetings.c.id == meeting_id)
+ meeting_result = await get_database().fetch_one(query)
+ assert meeting_result is None
+
+ # Verify recording was deleted
+ assert await recordings_controller.get_by_id(recording.id) is None
+
+
+@pytest.mark.asyncio
+async def test_cleanup_handles_errors_gracefully():
+ """Test that cleanup continues even when individual deletions fail."""
+ old_date = datetime.now(timezone.utc) - timedelta(days=8)
+
+ # Create multiple old transcripts
+ transcript1 = await transcripts_controller.add(
+ name="Transcript 1",
+ source_kind=SourceKind.FILE,
+ user_id=None,
+ )
+ transcript2 = await transcripts_controller.add(
+ name="Transcript 2",
+ source_kind=SourceKind.FILE,
+ user_id=None,
+ )
+
+ # Update created_at to be old
+ from reflector.db import get_database
+ from reflector.db.transcripts import transcripts
+
+ for t_id in [transcript1.id, transcript2.id]:
+ await get_database().execute(
+ transcripts.update()
+ .where(transcripts.c.id == t_id)
+ .values(created_at=old_date)
+ )
+
+ with patch("reflector.worker.cleanup.settings") as mock_settings:
+ mock_settings.PUBLIC_MODE = True
+ mock_settings.PUBLIC_DATA_RETENTION_DAYS = 7
+
+ # Mock remove_by_id to fail for the first transcript
+ original_remove = transcripts_controller.remove_by_id
+ call_count = 0
+
+ async def mock_remove_by_id(transcript_id, user_id=None):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 1:
+ raise Exception("Simulated deletion error")
+ return await original_remove(transcript_id, user_id)
+
+ with patch.object(
+ transcripts_controller, "remove_by_id", side_effect=mock_remove_by_id
+ ):
+ result = await cleanup_old_public_data()
+
+ # Should have one successful deletion and one error
+ assert result["transcripts_deleted"] == 1
+ assert len(result["errors"]) == 1
+ assert "Failed to delete transcript" in result["errors"][0]
+
+
+@pytest.mark.asyncio
+async def test_meeting_consent_cascade_delete():
+ """Test that meeting_consent records are automatically deleted when meeting is deleted."""
+ from reflector.db import get_database
+ from reflector.db.meetings import (
+ meeting_consent,
+ meeting_consent_controller,
+ meetings,
+ )
+
+ # Create a meeting
+ meeting_id = "test-cascade-meeting"
+ await get_database().execute(
+ meetings.insert().values(
+ id=meeting_id,
+ room_name="Test Meeting for CASCADE",
+ room_url="https://example.com/cascade-test",
+ host_room_url="https://example.com/cascade-test-host",
+ start_date=datetime.now(timezone.utc),
+ end_date=datetime.now(timezone.utc) + timedelta(hours=1),
+ user_id="test-user",
+ room_id=None,
+ )
+ )
+
+ # Create consent records for this meeting
+ consent1_id = "consent-1"
+ consent2_id = "consent-2"
+
+ await get_database().execute(
+ meeting_consent.insert().values(
+ id=consent1_id,
+ meeting_id=meeting_id,
+ user_id="user1",
+ consent_given=True,
+ consent_timestamp=datetime.now(timezone.utc),
+ )
+ )
+
+ await get_database().execute(
+ meeting_consent.insert().values(
+ id=consent2_id,
+ meeting_id=meeting_id,
+ user_id="user2",
+ consent_given=False,
+ consent_timestamp=datetime.now(timezone.utc),
+ )
+ )
+
+ # Verify consent records exist
+ consents = await meeting_consent_controller.get_by_meeting_id(meeting_id)
+ assert len(consents) == 2
+
+ # Delete the meeting
+ await get_database().execute(meetings.delete().where(meetings.c.id == meeting_id))
+
+ # Verify meeting is deleted
+ query = meetings.select().where(meetings.c.id == meeting_id)
+ result = await get_database().fetch_one(query)
+ assert result is None
+
+ # Verify consent records are automatically deleted (CASCADE DELETE)
+ consents_after = await meeting_consent_controller.get_by_meeting_id(meeting_id)
+ assert len(consents_after) == 0
From 88ed7cfa7804794b9b54cad4c3facc8a98cf85fd Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Fri, 29 Aug 2025 10:07:49 -0600
Subject: [PATCH 07/77] feat(rooms): add webhook for transcript completion
(#578)
* feat(rooms): add webhook notifications for transcript completion
- Add webhook_url and webhook_secret fields to rooms table
- Create Celery task with 24-hour retry window using exponential backoff
- Send transcript metadata, diarized text, topics, and summaries via webhook
- Add HMAC signature verification for webhook security
- Add test endpoint POST /v1/rooms/{room_id}/webhook/test
- Update frontend with webhook configuration UI and test button
- Auto-generate webhook secret if not provided
- Trigger webhook after successful file pipeline processing for room recordings
* style: linting
* fix: remove unwanted files
* fix: update openapi gen
* fix: self-review
* docs: add comprehensive webhook documentation
- Document webhook configuration, events, and payloads
- Include transcript.completed and test event examples
- Add security considerations and best practices
- Provide example webhook receiver implementation
- Document retry policy and signature verification
* fix: remove audio_mp3_url from webhook payload
- Remove audio download URL generation from webhook
- Update documentation to reflect the change
- Keep only frontend_url for accessing transcripts
* docs: remove unwanted section
* fix: correct API method name and type imports for rooms
- Fix v1RoomsRetrieve to v1RoomsGet
- Update Room type to RoomDetails throughout frontend
- Fix type imports in useRoomList, RoomList, RoomTable, and RoomCards
* feat: add show/hide toggle for webhook secret field
- Add eye icon button to reveal/hide webhook secret when editing
- Show password dots when webhook secret is hidden
- Reset visibility state when opening/closing dialog
- Only show toggle button when editing existing room with secret
* fix: resolve event loop conflict in webhook test endpoint
- Extract webhook test logic into shared async function
- Call async function directly from FastAPI endpoint
- Keep Celery task wrapper for background processing
- Fixes RuntimeError: event loop already running
* refactor: remove unnecessary Celery task for webhook testing
- Webhook testing is synchronous and provides immediate feedback
- No need for background processing via Celery
- Keep only the async function called directly from API endpoint
* feat: improve webhook test error messages and display
- Show HTTP status code in error messages
- Parse JSON error responses to extract meaningful messages
- Improved UI layout for webhook test results
- Added colored background for success/error states
- Better text wrapping for long error messages
* docs: adjust doc
* fix: review
* fix: update attempts to match close 24h
* fix: add event_id
* fix: changed to uuid, to have new event_id when reprocess.
* style: linting
* fix: alembic revision
---
server/docs/webhook.md | 212 ++++++++++++++
...194f65cd6d3_add_webhook_fields_to_rooms.py | 36 +++
server/reflector/db/rooms.py | 15 +
.../reflector/pipelines/main_file_pipeline.py | 19 +-
server/reflector/views/rooms.py | 59 +++-
server/reflector/worker/webhook.py | 258 ++++++++++++++++++
www/app/(app)/rooms/_components/RoomCards.tsx | 4 +-
www/app/(app)/rooms/_components/RoomList.tsx | 4 +-
www/app/(app)/rooms/_components/RoomTable.tsx | 4 +-
www/app/(app)/rooms/page.tsx | 243 +++++++++++++++--
www/app/(app)/rooms/useRoomList.tsx | 6 +-
www/app/api/schemas.gen.ts | 150 +++++++++-
www/app/api/services.gen.ts | 53 +++-
www/app/api/types.gen.ts | 81 +++++-
14 files changed, 1102 insertions(+), 42 deletions(-)
create mode 100644 server/docs/webhook.md
create mode 100644 server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py
create mode 100644 server/reflector/worker/webhook.py
diff --git a/server/docs/webhook.md b/server/docs/webhook.md
new file mode 100644
index 00000000..9fe88fb9
--- /dev/null
+++ b/server/docs/webhook.md
@@ -0,0 +1,212 @@
+# Reflector Webhook Documentation
+
+## Overview
+
+Reflector supports webhook notifications to notify external systems when transcript processing is completed. Webhooks can be configured per room and are triggered automatically after a transcript is successfully processed.
+
+## Configuration
+
+Webhooks are configured at the room level with two fields:
+- `webhook_url`: The HTTPS endpoint to receive webhook notifications
+- `webhook_secret`: Optional secret key for HMAC signature verification (auto-generated if not provided)
+
+## Events
+
+### `transcript.completed`
+
+Triggered when a transcript has been fully processed, including transcription, diarization, summarization, and topic detection.
+
+### `test`
+
+A test event that can be triggered manually to verify webhook configuration.
+
+## Webhook Request Format
+
+### Headers
+
+All webhook requests include the following headers:
+
+| Header | Description | Example |
+|--------|-------------|---------|
+| `Content-Type` | Always `application/json` | `application/json` |
+| `User-Agent` | Identifies Reflector as the source | `Reflector-Webhook/1.0` |
+| `X-Webhook-Event` | The event type | `transcript.completed` or `test` |
+| `X-Webhook-Retry` | Current retry attempt number | `0`, `1`, `2`... |
+| `X-Webhook-Signature` | HMAC signature (if secret configured) | `t=1735306800,v1=abc123...` |
+
+### Signature Verification
+
+If a webhook secret is configured, Reflector includes an HMAC-SHA256 signature in the `X-Webhook-Signature` header to verify the webhook authenticity.
+
+The signature format is: `t={timestamp},v1={signature}`
+
+To verify the signature:
+1. Extract the timestamp and signature from the header
+2. Create the signed payload: `{timestamp}.{request_body}`
+3. Compute HMAC-SHA256 of the signed payload using your webhook secret
+4. Compare the computed signature with the received signature
+
+Example verification (Python):
+```python
+import hmac
+import hashlib
+
+def verify_webhook_signature(payload: bytes, signature_header: str, secret: str) -> bool:
+ # Parse header: "t=1735306800,v1=abc123..."
+ parts = dict(part.split("=") for part in signature_header.split(","))
+ timestamp = parts["t"]
+ received_signature = parts["v1"]
+
+ # Create signed payload
+ signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
+
+ # Compute expected signature
+ expected_signature = hmac.new(
+ secret.encode("utf-8"),
+ signed_payload.encode("utf-8"),
+ hashlib.sha256
+ ).hexdigest()
+
+ # Compare signatures
+ return hmac.compare_digest(expected_signature, received_signature)
+```
+
+## Event Payloads
+
+### `transcript.completed` Event
+
+This event includes a convenient URL for accessing the transcript:
+- `frontend_url`: Direct link to view the transcript in the web interface
+
+```json
+{
+ "event": "transcript.completed",
+ "event_id": "transcript.completed-abc-123-def-456",
+ "timestamp": "2025-08-27T12:34:56.789012Z",
+ "transcript": {
+ "id": "abc-123-def-456",
+ "room_id": "room-789",
+ "created_at": "2025-08-27T12:00:00Z",
+ "duration": 1800.5,
+ "title": "Q3 Product Planning Meeting",
+ "short_summary": "Team discussed Q3 product roadmap, prioritizing mobile app features and API improvements.",
+ "long_summary": "The product team met to finalize the Q3 roadmap. Key decisions included...",
+ "webvtt": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nWelcome everyone to today's meeting...",
+ "topics": [
+ {
+ "title": "Introduction and Agenda",
+ "summary": "Meeting kickoff with agenda review",
+ "timestamp": 0.0,
+ "duration": 120.0,
+ "webvtt": "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nWelcome everyone..."
+ },
+ {
+ "title": "Mobile App Features Discussion",
+ "summary": "Team reviewed proposed mobile app features for Q3",
+ "timestamp": 120.0,
+ "duration": 600.0,
+ "webvtt": "WEBVTT\n\n00:02:00.000 --> 00:02:10.000\nLet's talk about the mobile app..."
+ }
+ ],
+ "participants": [
+ {
+ "id": "participant-1",
+ "name": "John Doe",
+ "speaker": "Speaker 1"
+ },
+ {
+ "id": "participant-2",
+ "name": "Jane Smith",
+ "speaker": "Speaker 2"
+ }
+ ],
+ "source_language": "en",
+ "target_language": "en",
+ "status": "completed",
+ "frontend_url": "https://app.reflector.com/transcripts/abc-123-def-456"
+ },
+ "room": {
+ "id": "room-789",
+ "name": "Product Team Room"
+ }
+}
+```
+
+### `test` Event
+
+```json
+{
+ "event": "test",
+ "event_id": "test.2025-08-27T12:34:56.789012Z",
+ "timestamp": "2025-08-27T12:34:56.789012Z",
+ "message": "This is a test webhook from Reflector",
+ "room": {
+ "id": "room-789",
+ "name": "Product Team Room"
+ }
+}
+```
+
+## Retry Policy
+
+Webhooks are delivered with automatic retry logic to handle transient failures. When a webhook delivery fails due to server errors or network issues, Reflector will automatically retry the delivery multiple times over an extended period.
+
+### Retry Mechanism
+
+Reflector implements an exponential backoff strategy for webhook retries:
+
+- **Initial retry delay**: 60 seconds after the first failure
+- **Exponential backoff**: Each subsequent retry waits approximately twice as long as the previous one
+- **Maximum retry interval**: 1 hour (backoff is capped at this duration)
+- **Maximum retry attempts**: 30 attempts total
+- **Total retry duration**: Retries continue for approximately 24 hours
+
+### How Retries Work
+
+When a webhook fails, Reflector will:
+1. Wait 60 seconds, then retry (attempt #1)
+2. If it fails again, wait ~2 minutes, then retry (attempt #2)
+3. Continue doubling the wait time up to a maximum of 1 hour between attempts
+4. Keep retrying at 1-hour intervals until successful or 30 attempts are exhausted
+
+The `X-Webhook-Retry` header indicates the current retry attempt number (0 for the initial attempt, 1 for first retry, etc.), allowing your endpoint to track retry attempts.
+
+### Retry Behavior by HTTP Status Code
+
+| Status Code | Behavior |
+|-------------|----------|
+| 2xx (Success) | No retry, webhook marked as delivered |
+| 4xx (Client Error) | No retry, request is considered permanently failed |
+| 5xx (Server Error) | Automatic retry with exponential backoff |
+| Network/Timeout Error | Automatic retry with exponential backoff |
+
+**Important Notes:**
+- Webhooks timeout after 30 seconds. If your endpoint takes longer to respond, it will be considered a timeout error and retried.
+- During the retry period (~24 hours), you may receive the same webhook multiple times if your endpoint experiences intermittent failures.
+- There is no mechanism to manually retry failed webhooks after the retry period expires.
+
+## Testing Webhooks
+
+You can test your webhook configuration before processing transcripts:
+
+```http
+POST /v1/rooms/{room_id}/webhook/test
+```
+
+Response:
+```json
+{
+ "success": true,
+ "status_code": 200,
+ "message": "Webhook test successful",
+ "response_preview": "OK"
+}
+```
+
+Or in case of failure:
+```json
+{
+ "success": false,
+ "error": "Webhook request timed out (10 seconds)"
+}
+```
diff --git a/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py b/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py
new file mode 100644
index 00000000..21dc1260
--- /dev/null
+++ b/server/migrations/versions/0194f65cd6d3_add_webhook_fields_to_rooms.py
@@ -0,0 +1,36 @@
+"""Add webhook fields to rooms
+
+Revision ID: 0194f65cd6d3
+Revises: 5a8907fd1d78
+Create Date: 2025-08-27 09:03:19.610995
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "0194f65cd6d3"
+down_revision: Union[str, None] = "5a8907fd1d78"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("webhook_url", sa.String(), nullable=True))
+ batch_op.add_column(sa.Column("webhook_secret", sa.String(), nullable=True))
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.drop_column("webhook_secret")
+ batch_op.drop_column("webhook_url")
+
+ # ### end Alembic commands ###
diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py
index a38e6b7f..08a6748d 100644
--- a/server/reflector/db/rooms.py
+++ b/server/reflector/db/rooms.py
@@ -1,3 +1,4 @@
+import secrets
from datetime import datetime, timezone
from sqlite3 import IntegrityError
from typing import Literal
@@ -40,6 +41,8 @@ rooms = sqlalchemy.Table(
sqlalchemy.Column(
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
),
+ sqlalchemy.Column("webhook_url", sqlalchemy.String),
+ sqlalchemy.Column("webhook_secret", sqlalchemy.String),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
)
@@ -59,6 +62,8 @@ class Room(BaseModel):
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
is_shared: bool = False
+ webhook_url: str = ""
+ webhook_secret: str = ""
class RoomController:
@@ -107,10 +112,15 @@ class RoomController:
recording_type: str,
recording_trigger: str,
is_shared: bool,
+ webhook_url: str = "",
+ webhook_secret: str = "",
):
"""
Add a new room
"""
+ if webhook_url and not webhook_secret:
+ webhook_secret = secrets.token_urlsafe(32)
+
room = Room(
name=name,
user_id=user_id,
@@ -122,6 +132,8 @@ class RoomController:
recording_type=recording_type,
recording_trigger=recording_trigger,
is_shared=is_shared,
+ webhook_url=webhook_url,
+ webhook_secret=webhook_secret,
)
query = rooms.insert().values(**room.model_dump())
try:
@@ -134,6 +146,9 @@ class RoomController:
"""
Update a room fields with key/values in values
"""
+ if values.get("webhook_url") and not values.get("webhook_secret"):
+ values["webhook_secret"] = secrets.token_urlsafe(32)
+
query = rooms.update().where(rooms.c.id == room.id).values(**values)
try:
await get_database().execute(query)
diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py
index 42333aa9..5c57dddb 100644
--- a/server/reflector/pipelines/main_file_pipeline.py
+++ b/server/reflector/pipelines/main_file_pipeline.py
@@ -7,6 +7,7 @@ Uses parallel processing for transcription, diarization, and waveform generation
"""
import asyncio
+import uuid
from pathlib import Path
import av
@@ -14,7 +15,9 @@ import structlog
from celery import shared_task
from reflector.asynctask import asynctask
+from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import (
+ SourceKind,
Transcript,
TranscriptStatus,
transcripts_controller,
@@ -48,6 +51,7 @@ from reflector.processors.types import (
)
from reflector.settings import settings
from reflector.storage import get_transcripts_storage
+from reflector.worker.webhook import send_transcript_webhook
class EmptyPipeline:
@@ -385,7 +389,6 @@ async def task_pipeline_file_process(*, transcript_id: str):
raise Exception(f"Transcript {transcript_id} not found")
pipeline = PipelineMainFile(transcript_id=transcript_id)
-
try:
await pipeline.set_status(transcript_id, "processing")
@@ -402,3 +405,17 @@ async def task_pipeline_file_process(*, transcript_id: str):
except Exception:
await pipeline.set_status(transcript_id, "error")
raise
+
+ # Trigger webhook if this is a room recording with webhook configured
+ if transcript.source_kind == SourceKind.ROOM and transcript.room_id:
+ room = await rooms_controller.get_by_id(transcript.room_id)
+ if room and room.webhook_url:
+ logger.info(
+ "Dispatching webhook task",
+ transcript_id=transcript_id,
+ room_id=room.id,
+ webhook_url=room.webhook_url,
+ )
+ send_transcript_webhook.delay(
+ transcript_id, room.id, event_id=uuid.uuid4().hex
+ )
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index d4278e1f..82c172f2 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -15,6 +15,7 @@ from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.settings import settings
from reflector.whereby import create_meeting, upload_logo
+from reflector.worker.webhook import test_webhook
logger = logging.getLogger(__name__)
@@ -44,6 +45,11 @@ class Room(BaseModel):
is_shared: bool
+class RoomDetails(Room):
+ webhook_url: str
+ webhook_secret: str
+
+
class Meeting(BaseModel):
id: str
room_name: str
@@ -64,6 +70,8 @@ class CreateRoom(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
+ webhook_url: str
+ webhook_secret: str
class UpdateRoom(BaseModel):
@@ -76,16 +84,26 @@ class UpdateRoom(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
+ webhook_url: str
+ webhook_secret: str
class DeletionStatus(BaseModel):
status: str
-@router.get("/rooms", response_model=Page[Room])
+class WebhookTestResult(BaseModel):
+ success: bool
+ message: str = ""
+ error: str = ""
+ status_code: int | None = None
+ response_preview: str | None = None
+
+
+@router.get("/rooms", response_model=Page[RoomDetails])
async def rooms_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
-) -> list[Room]:
+) -> list[RoomDetails]:
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
@@ -99,6 +117,18 @@ async def rooms_list(
)
+@router.get("/rooms/{room_id}", response_model=RoomDetails)
+async def rooms_get(
+ room_id: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ user_id = user["sub"] if user else None
+ room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+ return room
+
+
@router.post("/rooms", response_model=Room)
async def rooms_create(
room: CreateRoom,
@@ -117,10 +147,12 @@ async def rooms_create(
recording_type=room.recording_type,
recording_trigger=room.recording_trigger,
is_shared=room.is_shared,
+ webhook_url=room.webhook_url,
+ webhook_secret=room.webhook_secret,
)
-@router.patch("/rooms/{room_id}", response_model=Room)
+@router.patch("/rooms/{room_id}", response_model=RoomDetails)
async def rooms_update(
room_id: str,
info: UpdateRoom,
@@ -209,3 +241,24 @@ async def rooms_create_meeting(
meeting.host_room_url = ""
return meeting
+
+
+@router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult)
+async def rooms_test_webhook(
+ room_id: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ """Test webhook configuration by sending a sample payload."""
+ user_id = user["sub"] if user else None
+
+ room = await rooms_controller.get_by_id(room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ if user_id and room.user_id != user_id:
+ raise HTTPException(
+ status_code=403, detail="Not authorized to test this room's webhook"
+ )
+
+ result = await test_webhook(room_id)
+ return WebhookTestResult(**result)
diff --git a/server/reflector/worker/webhook.py b/server/reflector/worker/webhook.py
new file mode 100644
index 00000000..64368b2e
--- /dev/null
+++ b/server/reflector/worker/webhook.py
@@ -0,0 +1,258 @@
+"""Webhook task for sending transcript notifications."""
+
+import hashlib
+import hmac
+import json
+import uuid
+from datetime import datetime, timezone
+
+import httpx
+import structlog
+from celery import shared_task
+from celery.utils.log import get_task_logger
+
+from reflector.db.rooms import rooms_controller
+from reflector.db.transcripts import transcripts_controller
+from reflector.pipelines.main_live_pipeline import asynctask
+from reflector.settings import settings
+from reflector.utils.webvtt import topics_to_webvtt
+
+logger = structlog.wrap_logger(get_task_logger(__name__))
+
+
+def generate_webhook_signature(payload: bytes, secret: str, timestamp: str) -> str:
+ """Generate HMAC signature for webhook payload."""
+ signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
+ hmac_obj = hmac.new(
+ secret.encode("utf-8"),
+ signed_payload.encode("utf-8"),
+ hashlib.sha256,
+ )
+ return hmac_obj.hexdigest()
+
+
+@shared_task(
+ bind=True,
+ max_retries=30,
+ default_retry_delay=60,
+ retry_backoff=True,
+ retry_backoff_max=3600, # Max 1 hour between retries
+)
+@asynctask
+async def send_transcript_webhook(
+ self,
+ transcript_id: str,
+ room_id: str,
+ event_id: str,
+):
+ log = logger.bind(
+ transcript_id=transcript_id,
+ room_id=room_id,
+ retry_count=self.request.retries,
+ )
+
+ try:
+ # Fetch transcript and room
+ transcript = await transcripts_controller.get_by_id(transcript_id)
+ if not transcript:
+ log.error("Transcript not found, skipping webhook")
+ return
+
+ room = await rooms_controller.get_by_id(room_id)
+ if not room:
+ log.error("Room not found, skipping webhook")
+ return
+
+ if not room.webhook_url:
+ log.info("No webhook URL configured for room, skipping")
+ return
+
+ # Generate WebVTT content from topics
+ topics_data = []
+
+ if transcript.topics:
+ # Build topics data with diarized content per topic
+ for topic in transcript.topics:
+ topic_webvtt = topics_to_webvtt([topic]) if topic.words else ""
+ topics_data.append(
+ {
+ "title": topic.title,
+ "summary": topic.summary,
+ "timestamp": topic.timestamp,
+ "duration": topic.duration,
+ "webvtt": topic_webvtt,
+ }
+ )
+
+ # Build webhook payload
+ frontend_url = f"{settings.UI_BASE_URL}/transcripts/{transcript.id}"
+ participants = [
+ {"id": p.id, "name": p.name, "speaker": p.speaker}
+ for p in (transcript.participants or [])
+ ]
+ payload_data = {
+ "event": "transcript.completed",
+ "event_id": event_id,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "transcript": {
+ "id": transcript.id,
+ "room_id": transcript.room_id,
+ "created_at": transcript.created_at.isoformat(),
+ "duration": transcript.duration,
+ "title": transcript.title,
+ "short_summary": transcript.short_summary,
+ "long_summary": transcript.long_summary,
+ "webvtt": transcript.webvtt,
+ "topics": topics_data,
+ "participants": participants,
+ "source_language": transcript.source_language,
+ "target_language": transcript.target_language,
+ "status": transcript.status,
+ "frontend_url": frontend_url,
+ },
+ "room": {
+ "id": room.id,
+ "name": room.name,
+ },
+ }
+
+ # Convert to JSON
+ payload_json = json.dumps(payload_data, separators=(",", ":"))
+ payload_bytes = payload_json.encode("utf-8")
+
+ # Generate signature if secret is configured
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "Reflector-Webhook/1.0",
+ "X-Webhook-Event": "transcript.completed",
+ "X-Webhook-Retry": str(self.request.retries),
+ }
+
+ if room.webhook_secret:
+ timestamp = str(int(datetime.now(timezone.utc).timestamp()))
+ signature = generate_webhook_signature(
+ payload_bytes, room.webhook_secret, timestamp
+ )
+ headers["X-Webhook-Signature"] = f"t={timestamp},v1={signature}"
+
+ # Send webhook with timeout
+ async with httpx.AsyncClient(timeout=30.0) as client:
+ log.info(
+ "Sending webhook",
+ url=room.webhook_url,
+ payload_size=len(payload_bytes),
+ )
+
+ response = await client.post(
+ room.webhook_url,
+ content=payload_bytes,
+ headers=headers,
+ )
+
+ response.raise_for_status()
+
+ log.info(
+ "Webhook sent successfully",
+ status_code=response.status_code,
+ response_size=len(response.content),
+ )
+
+ except httpx.HTTPStatusError as e:
+ log.error(
+ "Webhook failed with HTTP error",
+ status_code=e.response.status_code,
+ response_text=e.response.text[:500], # First 500 chars
+ )
+
+ # Don't retry on client errors (4xx)
+ if 400 <= e.response.status_code < 500:
+ log.error("Client error, not retrying")
+ return
+
+ # Retry on server errors (5xx)
+ raise self.retry(exc=e)
+
+ except (httpx.ConnectError, httpx.TimeoutException) as e:
+ # Retry on network errors
+ log.error("Webhook failed with connection error", error=str(e))
+ raise self.retry(exc=e)
+
+ except Exception as e:
+ # Retry on unexpected errors
+ log.exception("Unexpected error in webhook task", error=str(e))
+ raise self.retry(exc=e)
+
+
+async def test_webhook(room_id: str) -> dict:
+ """
+ Test webhook configuration by sending a sample payload.
+ Returns immediately with success/failure status.
+ This is the shared implementation used by both the API endpoint and Celery task.
+ """
+ try:
+ room = await rooms_controller.get_by_id(room_id)
+ if not room:
+ return {"success": False, "error": "Room not found"}
+
+ if not room.webhook_url:
+ return {"success": False, "error": "No webhook URL configured"}
+
+ now = (datetime.now(timezone.utc).isoformat(),)
+ payload_data = {
+ "event": "test",
+ "event_id": uuid.uuid4().hex,
+ "timestamp": now,
+ "message": "This is a test webhook from Reflector",
+ "room": {
+ "id": room.id,
+ "name": room.name,
+ },
+ }
+
+ payload_json = json.dumps(payload_data, separators=(",", ":"))
+ payload_bytes = payload_json.encode("utf-8")
+
+ # Generate headers with signature
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "Reflector-Webhook/1.0",
+ "X-Webhook-Event": "test",
+ }
+
+ if room.webhook_secret:
+ timestamp = str(int(datetime.now(timezone.utc).timestamp()))
+ signature = generate_webhook_signature(
+ payload_bytes, room.webhook_secret, timestamp
+ )
+ headers["X-Webhook-Signature"] = f"t={timestamp},v1={signature}"
+
+ # Send test webhook with short timeout
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ response = await client.post(
+ room.webhook_url,
+ content=payload_bytes,
+ headers=headers,
+ )
+
+ return {
+ "success": response.is_success,
+ "status_code": response.status_code,
+ "message": f"Webhook test {'successful' if response.is_success else 'failed'}",
+ "response_preview": response.text if response.text else None,
+ }
+
+ except httpx.TimeoutException:
+ return {
+ "success": False,
+ "error": "Webhook request timed out (10 seconds)",
+ }
+ except httpx.ConnectError as e:
+ return {
+ "success": False,
+ "error": f"Could not connect to webhook URL: {str(e)}",
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "error": f"Unexpected error: {str(e)}",
+ }
diff --git a/www/app/(app)/rooms/_components/RoomCards.tsx b/www/app/(app)/rooms/_components/RoomCards.tsx
index 15079a7a..16748d90 100644
--- a/www/app/(app)/rooms/_components/RoomCards.tsx
+++ b/www/app/(app)/rooms/_components/RoomCards.tsx
@@ -12,11 +12,11 @@ import {
HStack,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
-import { Room } from "../../../api";
+import { RoomDetails } from "../../../api";
import { RoomActionsMenu } from "./RoomActionsMenu";
interface RoomCardsProps {
- rooms: Room[];
+ rooms: RoomDetails[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;
diff --git a/www/app/(app)/rooms/_components/RoomList.tsx b/www/app/(app)/rooms/_components/RoomList.tsx
index 17cd5fc5..73fe8a5c 100644
--- a/www/app/(app)/rooms/_components/RoomList.tsx
+++ b/www/app/(app)/rooms/_components/RoomList.tsx
@@ -1,11 +1,11 @@
import { Box, Heading, Text, VStack } from "@chakra-ui/react";
-import { Room } from "../../../api";
+import { RoomDetails } from "../../../api";
import { RoomTable } from "./RoomTable";
import { RoomCards } from "./RoomCards";
interface RoomListProps {
title: string;
- rooms: Room[];
+ rooms: RoomDetails[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;
diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx
index 092fccdc..93d05b61 100644
--- a/www/app/(app)/rooms/_components/RoomTable.tsx
+++ b/www/app/(app)/rooms/_components/RoomTable.tsx
@@ -9,11 +9,11 @@ import {
Spinner,
} from "@chakra-ui/react";
import { LuLink } from "react-icons/lu";
-import { Room } from "../../../api";
+import { RoomDetails } from "../../../api";
import { RoomActionsMenu } from "./RoomActionsMenu";
interface RoomTableProps {
- rooms: Room[];
+ rooms: RoomDetails[];
linkCopied: string;
onCopyUrl: (roomName: string) => void;
onEdit: (roomId: string, roomData: any) => void;
diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx
index 305087f9..33cfa6b3 100644
--- a/www/app/(app)/rooms/page.tsx
+++ b/www/app/(app)/rooms/page.tsx
@@ -11,13 +11,15 @@ import {
Input,
Select,
Spinner,
+ IconButton,
createListCollection,
useDisclosure,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
+import { LuEye, LuEyeOff } from "react-icons/lu";
import useApi from "../../lib/useApi";
import useRoomList from "./useRoomList";
-import { ApiError, Room } from "../../api";
+import { ApiError, RoomDetails } from "../../api";
import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination";
@@ -55,6 +57,8 @@ const roomInitialState = {
recordingType: "cloud",
recordingTrigger: "automatic-2nd-participant",
isShared: false,
+ webhookUrl: "",
+ webhookSecret: "",
};
export default function RoomsList() {
@@ -83,6 +87,11 @@ export default function RoomsList() {
const [topics, setTopics] = useState([]);
const [nameError, setNameError] = useState("");
const [linkCopied, setLinkCopied] = useState("");
+ const [testingWebhook, setTestingWebhook] = useState(false);
+ const [webhookTestResult, setWebhookTestResult] = useState(
+ null,
+ );
+ const [showWebhookSecret, setShowWebhookSecret] = useState(false);
interface Stream {
stream_id: number;
name: string;
@@ -155,6 +164,69 @@ export default function RoomsList() {
}, 2000);
};
+ const handleCloseDialog = () => {
+ setShowWebhookSecret(false);
+ setWebhookTestResult(null);
+ onClose();
+ };
+
+ const handleTestWebhook = async () => {
+ if (!room.webhookUrl || !editRoomId) {
+ setWebhookTestResult("Please enter a webhook URL first");
+ return;
+ }
+
+ setTestingWebhook(true);
+ setWebhookTestResult(null);
+
+ try {
+ const response = await api?.v1RoomsTestWebhook({
+ roomId: editRoomId,
+ });
+
+ if (response?.success) {
+ setWebhookTestResult(
+ `✅ Webhook test successful! Status: ${response.status_code}`,
+ );
+ } else {
+ let errorMsg = `❌ Webhook test failed`;
+ if (response?.status_code) {
+ errorMsg += ` (Status: ${response.status_code})`;
+ }
+ if (response?.error) {
+ errorMsg += `: ${response.error}`;
+ } else if (response?.response_preview) {
+ // Try to parse and extract meaningful error from response
+ // Specific to N8N at the moment, as there is no specification for that
+ // We could just display as is, but decided here to dig a little bit more.
+ try {
+ const preview = JSON.parse(response.response_preview);
+ if (preview.message) {
+ errorMsg += `: ${preview.message}`;
+ }
+ } catch {
+ // If not JSON, just show the preview text (truncated)
+ const previewText = response.response_preview.substring(0, 150);
+ errorMsg += `: ${previewText}`;
+ }
+ } else if (response?.message) {
+ errorMsg += `: ${response.message}`;
+ }
+ setWebhookTestResult(errorMsg);
+ }
+ } catch (error) {
+ console.error("Error testing webhook:", error);
+ setWebhookTestResult("❌ Failed to test webhook. Please check your URL.");
+ } finally {
+ setTestingWebhook(false);
+ }
+
+ // Clear result after 5 seconds
+ setTimeout(() => {
+ setWebhookTestResult(null);
+ }, 5000);
+ };
+
const handleSaveRoom = async () => {
try {
if (RESERVED_PATHS.includes(room.name)) {
@@ -172,6 +244,8 @@ export default function RoomsList() {
recording_type: room.recordingType,
recording_trigger: room.recordingTrigger,
is_shared: room.isShared,
+ webhook_url: room.webhookUrl,
+ webhook_secret: room.webhookSecret,
};
if (isEditing) {
@@ -190,7 +264,7 @@ export default function RoomsList() {
setEditRoomId("");
setNameError("");
refetch();
- onClose();
+ handleCloseDialog();
} catch (err) {
if (
err instanceof ApiError &&
@@ -206,18 +280,46 @@ export default function RoomsList() {
}
};
- const handleEditRoom = (roomId, roomData) => {
- setRoom({
- name: roomData.name,
- zulipAutoPost: roomData.zulip_auto_post,
- zulipStream: roomData.zulip_stream,
- zulipTopic: roomData.zulip_topic,
- isLocked: roomData.is_locked,
- roomMode: roomData.room_mode,
- recordingType: roomData.recording_type,
- recordingTrigger: roomData.recording_trigger,
- isShared: roomData.is_shared,
- });
+ const handleEditRoom = async (roomId, roomData) => {
+ // Reset states
+ setShowWebhookSecret(false);
+ setWebhookTestResult(null);
+
+ // Fetch full room details to get webhook fields
+ try {
+ const detailedRoom = await api?.v1RoomsGet({ roomId });
+ if (detailedRoom) {
+ setRoom({
+ name: detailedRoom.name,
+ zulipAutoPost: detailedRoom.zulip_auto_post,
+ zulipStream: detailedRoom.zulip_stream,
+ zulipTopic: detailedRoom.zulip_topic,
+ isLocked: detailedRoom.is_locked,
+ roomMode: detailedRoom.room_mode,
+ recordingType: detailedRoom.recording_type,
+ recordingTrigger: detailedRoom.recording_trigger,
+ isShared: detailedRoom.is_shared,
+ webhookUrl: detailedRoom.webhook_url || "",
+ webhookSecret: detailedRoom.webhook_secret || "",
+ });
+ }
+ } catch (error) {
+ console.error("Failed to fetch room details, using list data:", error);
+ // Fallback to using the data from the list
+ setRoom({
+ name: roomData.name,
+ zulipAutoPost: roomData.zulip_auto_post,
+ zulipStream: roomData.zulip_stream,
+ zulipTopic: roomData.zulip_topic,
+ isLocked: roomData.is_locked,
+ roomMode: roomData.room_mode,
+ recordingType: roomData.recording_type,
+ recordingTrigger: roomData.recording_trigger,
+ isShared: roomData.is_shared,
+ webhookUrl: roomData.webhook_url || "",
+ webhookSecret: roomData.webhook_secret || "",
+ });
+ }
setEditRoomId(roomId);
setIsEditing(true);
setNameError("");
@@ -250,9 +352,9 @@ export default function RoomsList() {
});
};
- const myRooms: Room[] =
+ const myRooms: RoomDetails[] =
response?.items.filter((roomData) => !roomData.is_shared) || [];
- const sharedRooms: Room[] =
+ const sharedRooms: RoomDetails[] =
response?.items.filter((roomData) => roomData.is_shared) || [];
if (loading && !response)
@@ -287,6 +389,8 @@ export default function RoomsList() {
setIsEditing(false);
setRoom(roomInitialState);
setNameError("");
+ setShowWebhookSecret(false);
+ setWebhookTestResult(null);
onOpen();
}}
>
@@ -296,7 +400,7 @@ export default function RoomsList() {
(e.open ? onOpen() : onClose())}
+ onOpenChange={(e) => (e.open ? onOpen() : handleCloseDialog())}
size="lg"
>
@@ -533,6 +637,109 @@ export default function RoomsList() {
+
+ {/* Webhook Configuration Section */}
+
+ Webhook URL
+
+
+ Optional: URL to receive notifications when transcripts are
+ ready
+
+
+
+ {room.webhookUrl && (
+ <>
+
+ Webhook Secret
+
+
+ {isEditing && room.webhookSecret && (
+
+ setShowWebhookSecret(!showWebhookSecret)
+ }
+ >
+ {showWebhookSecret ? : }
+
+ )}
+
+
+ Used for HMAC signature verification (auto-generated if
+ left empty)
+
+
+
+ {isEditing && (
+ <>
+
+
+ {webhookTestResult && (
+
+ {webhookTestResult}
+
+ )}
+
+ >
+ )}
+ >
+ )}
+
-
}>
-
-
- {children}
-
-
-
-
-
+
+
+ "something went really wrong"}>
+
+
+ {children}
+
+
+
+
);
diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx
new file mode 100644
index 00000000..96f49f87
--- /dev/null
+++ b/www/app/lib/AuthProvider.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import { useSession as useNextAuthSession } from "next-auth/react";
+import { signOut, signIn } from "next-auth/react";
+import { configureApiAuth } from "./apiClient";
+import { assertCustomSession, CustomSession } from "./types";
+import { Session } from "next-auth";
+import { SessionAutoRefresh } from "./SessionAutoRefresh";
+import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
+
+type AuthContextType = (
+ | { status: "loading" }
+ | { status: "refreshing" }
+ | { status: "unauthenticated"; error?: string }
+ | {
+ status: "authenticated";
+ accessToken: string;
+ accessTokenExpires: number;
+ user: CustomSession["user"];
+ }
+) & {
+ update: () => Promise;
+ signIn: typeof signIn;
+ signOut: typeof signOut;
+};
+
+const AuthContext = createContext(undefined);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const { data: session, status, update } = useNextAuthSession();
+ const customSession = session ? assertCustomSession(session) : null;
+
+ const contextValue: AuthContextType = {
+ ...(() => {
+ switch (status) {
+ case "loading": {
+ const sessionIsHere = !!customSession;
+ switch (sessionIsHere) {
+ case false: {
+ return { status };
+ }
+ case true: {
+ return { status: "refreshing" as const };
+ }
+ default: {
+ const _: never = sessionIsHere;
+ throw new Error("unreachable");
+ }
+ }
+ }
+ case "authenticated": {
+ if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
+ // token had expired but next auth still returns "authenticated" so show user unauthenticated state
+ return {
+ status: "unauthenticated" as const,
+ };
+ } else if (customSession?.accessToken) {
+ return {
+ status,
+ accessToken: customSession.accessToken,
+ accessTokenExpires: customSession.accessTokenExpires,
+ user: customSession.user,
+ };
+ } else {
+ console.warn(
+ "illegal state: authenticated but have no session/or access token. ignoring",
+ );
+ return { status: "unauthenticated" as const };
+ }
+ }
+ case "unauthenticated": {
+ return { status: "unauthenticated" as const };
+ }
+ default: {
+ const _: never = status;
+ throw new Error("unreachable");
+ }
+ }
+ })(),
+ update,
+ signIn,
+ signOut,
+ };
+
+ // not useEffect, we need it ASAP
+ configureApiAuth(
+ contextValue.status === "authenticated" ? contextValue.accessToken : null,
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAuth() {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+}
diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx
index 1e230d6c..fd29367f 100644
--- a/www/app/lib/SessionAutoRefresh.tsx
+++ b/www/app/lib/SessionAutoRefresh.tsx
@@ -1,5 +1,5 @@
/**
- * This is a custom hook that automatically refreshes the session when the access token is about to expire.
+ * This is a custom provider that automatically refreshes the session when the access token is about to expire.
* When communicating with the reflector API, we need to ensure that the access token is always valid.
*
* We could have implemented that as an interceptor on the API client, but not everything is using the
@@ -7,30 +7,38 @@
*/
"use client";
-import { useSession } from "next-auth/react";
import { useEffect } from "react";
-import { CustomSession } from "./types";
+import { useAuth } from "./AuthProvider";
+import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
-export function SessionAutoRefresh({
- children,
- refreshInterval = 20 /* seconds */,
-}) {
- const { data: session, update } = useSession();
- const customSession = session as CustomSession;
- const accessTokenExpires = customSession?.accessTokenExpires;
+const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE;
+
+export function SessionAutoRefresh({ children }) {
+ const auth = useAuth();
+ const accessTokenExpires =
+ auth.status === "authenticated" ? auth.accessTokenExpires : null;
useEffect(() => {
+ // technical value for how often the setInterval will be polling news - not too fast (no spam in case of errors)
+ // and not too slow (debuggable)
+ const INTERVAL_REFRESH_MS = 5000;
const interval = setInterval(() => {
- if (accessTokenExpires) {
+ if (accessTokenExpires !== null) {
const timeLeft = accessTokenExpires - Date.now();
- if (timeLeft < refreshInterval * 1000) {
- update();
+ if (timeLeft < REFRESH_BEFORE) {
+ auth
+ .update()
+ .then(() => {})
+ .catch((e) => {
+ // note: 401 won't be considered error here
+ console.error("error refreshing auth token", e);
+ });
}
}
- }, refreshInterval * 1000);
+ }, INTERVAL_REFRESH_MS);
return () => clearInterval(interval);
- }, [accessTokenExpires, refreshInterval, update]);
+ }, [accessTokenExpires, auth.update]);
return children;
}
diff --git a/www/app/lib/SessionProvider.tsx b/www/app/lib/SessionProvider.tsx
deleted file mode 100644
index 9c95fbc8..00000000
--- a/www/app/lib/SessionProvider.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-"use client";
-import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
-import { SessionAutoRefresh } from "./SessionAutoRefresh";
-
-export default function SessionProvider({ children }) {
- return (
-
- {children}
-
- );
-}
diff --git a/www/app/lib/__tests__/redisTokenCache.test.ts b/www/app/lib/__tests__/redisTokenCache.test.ts
new file mode 100644
index 00000000..8ca8e8a1
--- /dev/null
+++ b/www/app/lib/__tests__/redisTokenCache.test.ts
@@ -0,0 +1,85 @@
+import {
+ getTokenCache,
+ setTokenCache,
+ deleteTokenCache,
+ TokenCacheEntry,
+ KV,
+} from "../redisTokenCache";
+
+const mockKV: KV & {
+ clear: () => void;
+} = (() => {
+ const data = new Map();
+ return {
+ async get(key: string): Promise {
+ return data.get(key) || null;
+ },
+
+ async setex(key: string, seconds_: number, value: string): Promise<"OK"> {
+ data.set(key, value);
+ return "OK";
+ },
+
+ async del(key: string): Promise {
+ const existed = data.has(key);
+ data.delete(key);
+ return existed ? 1 : 0;
+ },
+
+ clear() {
+ data.clear();
+ },
+ };
+})();
+
+describe("Redis Token Cache", () => {
+ beforeEach(() => {
+ mockKV.clear();
+ });
+
+ test("basic write/read - value written equals value read", async () => {
+ const testKey = "token:test-user-123";
+ const testValue: TokenCacheEntry = {
+ token: {
+ sub: "test-user-123",
+ name: "Test User",
+ email: "test@example.com",
+ accessToken: "access-token-123",
+ accessTokenExpires: Date.now() + 3600000, // 1 hour from now
+ refreshToken: "refresh-token-456",
+ },
+ timestamp: Date.now(),
+ };
+
+ await setTokenCache(mockKV, testKey, testValue);
+ const retrievedValue = await getTokenCache(mockKV, testKey);
+
+ expect(retrievedValue).not.toBeNull();
+ expect(retrievedValue).toEqual(testValue);
+ expect(retrievedValue?.token.accessToken).toBe(testValue.token.accessToken);
+ expect(retrievedValue?.token.sub).toBe(testValue.token.sub);
+ expect(retrievedValue?.timestamp).toBe(testValue.timestamp);
+ });
+
+ test("get returns null for non-existent key", async () => {
+ const result = await getTokenCache(mockKV, "non-existent-key");
+ expect(result).toBeNull();
+ });
+
+ test("delete removes token from cache", async () => {
+ const testKey = "token:delete-test";
+ const testValue: TokenCacheEntry = {
+ token: {
+ accessToken: "test-token",
+ accessTokenExpires: Date.now() + 3600000,
+ },
+ timestamp: Date.now(),
+ };
+
+ await setTokenCache(mockKV, testKey, testValue);
+ await deleteTokenCache(mockKV, testKey);
+
+ const result = await getTokenCache(mockKV, testKey);
+ expect(result).toBeNull();
+ });
+});
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
new file mode 100644
index 00000000..cd97e151
--- /dev/null
+++ b/www/app/lib/apiClient.tsx
@@ -0,0 +1,50 @@
+"use client";
+
+import createClient from "openapi-fetch";
+import type { paths } from "../reflector-api";
+import {
+ queryOptions,
+ useMutation,
+ useQuery,
+ useSuspenseQuery,
+} from "@tanstack/react-query";
+import createFetchClient from "openapi-react-query";
+import { assertExistsAndNonEmptyString } from "./utils";
+import { isBuildPhase } from "./next";
+
+const API_URL = !isBuildPhase
+ ? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
+ : "http://localhost";
+
+// Create the base openapi-fetch client with a default URL
+// The actual URL will be set via middleware in AuthProvider
+export const client = createClient({
+ baseUrl: API_URL,
+});
+
+export const $api = createFetchClient(client);
+
+let currentAuthToken: string | null | undefined = null;
+
+client.use({
+ onRequest({ request }) {
+ if (currentAuthToken) {
+ request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
+ }
+ // XXX Only set Content-Type if not already set (FormData will set its own boundary)
+ // This is a work around for uploading file, we're passing a formdata
+ // but the content type was still application/json
+ if (
+ !request.headers.has("Content-Type") &&
+ !(request.body instanceof FormData)
+ ) {
+ request.headers.set("Content-Type", "application/json");
+ }
+ return request;
+ },
+});
+
+// the function contract: lightweight, idempotent
+export const configureApiAuth = (token: string | null | undefined) => {
+ currentAuthToken = token;
+};
diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts
new file mode 100644
index 00000000..94d84c9b
--- /dev/null
+++ b/www/app/lib/apiHooks.ts
@@ -0,0 +1,618 @@
+"use client";
+
+import { $api } from "./apiClient";
+import { useError } from "../(errors)/errorContext";
+import { useQueryClient } from "@tanstack/react-query";
+import type { components } from "../reflector-api";
+import { useAuth } from "./AuthProvider";
+
+/*
+ * XXX error types returned from the hooks are not always correct; declared types are ValidationError but real type could be string or any other
+ * this is either a limitation or incorrect usage of Python json schema generator
+ * or, limitation or incorrect usage of .d type generator from json schema
+ * */
+
+const useAuthReady = () => {
+ const auth = useAuth();
+
+ return {
+ isAuthenticated: auth.status === "authenticated",
+ isLoading: auth.status === "loading",
+ };
+};
+
+export function useRoomsList(page: number = 1) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/rooms",
+ {
+ params: {
+ query: { page },
+ },
+ },
+ {
+ enabled: isAuthenticated,
+ },
+ );
+}
+
+type SourceKind = components["schemas"]["SourceKind"];
+
+export function useTranscriptsSearch(
+ q: string = "",
+ options: {
+ limit?: number;
+ offset?: number;
+ room_id?: string;
+ source_kind?: SourceKind;
+ } = {},
+) {
+ return $api.useQuery(
+ "get",
+ "/v1/transcripts/search",
+ {
+ params: {
+ query: {
+ q,
+ limit: options.limit,
+ offset: options.offset,
+ room_id: options.room_id,
+ source_kind: options.source_kind,
+ },
+ },
+ },
+ {
+ enabled: true,
+ },
+ );
+}
+
+export function useTranscriptDelete() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", {
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["get", "/v1/transcripts/search"],
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error deleting the transcript");
+ },
+ });
+}
+
+export function useTranscriptProcess() {
+ const { setError } = useError();
+
+ return $api.useMutation("post", "/v1/transcripts/{transcript_id}/process", {
+ onError: (error) => {
+ setError(error as Error, "There was an error processing the transcript");
+ },
+ });
+}
+
+export function useTranscriptGet(transcriptId: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/transcripts/{transcript_id}",
+ {
+ params: {
+ path: {
+ transcript_id: transcriptId || "",
+ },
+ },
+ },
+ {
+ enabled: !!transcriptId && isAuthenticated,
+ },
+ );
+}
+
+export function useRoomGet(roomId: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/rooms/{room_id}",
+ {
+ params: {
+ path: { room_id: roomId || "" },
+ },
+ },
+ {
+ enabled: !!roomId && isAuthenticated,
+ },
+ );
+}
+
+export function useRoomTestWebhook() {
+ const { setError } = useError();
+
+ return $api.useMutation("post", "/v1/rooms/{room_id}/webhook/test", {
+ onError: (error) => {
+ setError(error as Error, "There was an error testing the webhook");
+ },
+ });
+}
+
+export function useRoomCreate() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation("post", "/v1/rooms", {
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error creating the room");
+ },
+ });
+}
+
+export function useRoomUpdate() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation("patch", "/v1/rooms/{room_id}", {
+ onSuccess: async (room) => {
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
+ }),
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions("get", "/v1/rooms/{room_id}", {
+ params: {
+ path: {
+ room_id: room.id,
+ },
+ },
+ }).queryKey,
+ }),
+ ]);
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error updating the room");
+ },
+ });
+}
+
+export function useRoomDelete() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation("delete", "/v1/rooms/{room_id}", {
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error deleting the room");
+ },
+ });
+}
+
+export function useZulipStreams() {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/zulip/streams",
+ {},
+ {
+ enabled: isAuthenticated,
+ },
+ );
+}
+
+export function useZulipTopics(streamId: number | null) {
+ const { isAuthenticated } = useAuthReady();
+ const enabled = !!streamId && isAuthenticated;
+ return $api.useQuery(
+ "get",
+ "/v1/zulip/streams/{stream_id}/topics",
+ {
+ params: {
+ path: {
+ stream_id: enabled ? streamId : 0,
+ },
+ },
+ },
+ {
+ enabled,
+ },
+ );
+}
+
+export function useTranscriptUpdate() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", {
+ onSuccess: (data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ }).queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error updating the transcript");
+ },
+ });
+}
+
+export function useTranscriptPostToZulip() {
+ const { setError } = useError();
+
+ // @ts-ignore - Zulip endpoint not in OpenAPI spec
+ return $api.useMutation("post", "/v1/transcripts/{transcript_id}/zulip", {
+ onError: (error) => {
+ setError(error as Error, "There was an error posting to Zulip");
+ },
+ });
+}
+
+export function useTranscriptUploadAudio() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation(
+ "post",
+ "/v1/transcripts/{transcript_id}/record/upload",
+ {
+ onSuccess: (data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ },
+ ).queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error uploading the audio file");
+ },
+ },
+ );
+}
+
+export function useTranscriptWaveform(transcriptId: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/transcripts/{transcript_id}/audio/waveform",
+ {
+ params: {
+ path: { transcript_id: transcriptId || "" },
+ },
+ },
+ {
+ enabled: !!transcriptId && isAuthenticated,
+ },
+ );
+}
+
+export function useTranscriptMP3(transcriptId: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/transcripts/{transcript_id}/audio/mp3",
+ {
+ params: {
+ path: { transcript_id: transcriptId || "" },
+ },
+ },
+ {
+ enabled: !!transcriptId && isAuthenticated,
+ },
+ );
+}
+
+export function useTranscriptTopics(transcriptId: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/transcripts/{transcript_id}/topics",
+ {
+ params: {
+ path: { transcript_id: transcriptId || "" },
+ },
+ },
+ {
+ enabled: !!transcriptId && isAuthenticated,
+ },
+ );
+}
+
+export function useTranscriptTopicsWithWords(transcriptId: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/transcripts/{transcript_id}/topics/with-words",
+ {
+ params: {
+ path: { transcript_id: transcriptId || "" },
+ },
+ },
+ {
+ enabled: !!transcriptId && isAuthenticated,
+ },
+ );
+}
+
+export function useTranscriptTopicsWithWordsPerSpeaker(
+ transcriptId: string | null,
+ topicId: string | null,
+) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker",
+ {
+ params: {
+ path: {
+ transcript_id: transcriptId || "",
+ topic_id: topicId || "",
+ },
+ },
+ },
+ {
+ enabled: !!transcriptId && !!topicId && isAuthenticated,
+ },
+ );
+}
+
+export function useTranscriptParticipants(transcriptId: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ params: {
+ path: { transcript_id: transcriptId || "" },
+ },
+ },
+ {
+ enabled: !!transcriptId && isAuthenticated,
+ },
+ );
+}
+
+export function useTranscriptParticipantUpdate() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation(
+ "patch",
+ "/v1/transcripts/{transcript_id}/participants/{participant_id}",
+ {
+ onSuccess: (data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ },
+ ).queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error updating the participant");
+ },
+ },
+ );
+}
+
+export function useTranscriptParticipantCreate() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation(
+ "post",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ onSuccess: (data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ },
+ ).queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error creating the participant");
+ },
+ },
+ );
+}
+
+export function useTranscriptParticipantDelete() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation(
+ "delete",
+ "/v1/transcripts/{transcript_id}/participants/{participant_id}",
+ {
+ onSuccess: (data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ },
+ ).queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error deleting the participant");
+ },
+ },
+ );
+}
+
+export function useTranscriptSpeakerAssign() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation(
+ "patch",
+ "/v1/transcripts/{transcript_id}/speaker/assign",
+ {
+ onSuccess: (data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ },
+ ).queryKey,
+ });
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ },
+ ).queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error assigning the speaker");
+ },
+ },
+ );
+}
+
+export function useTranscriptSpeakerMerge() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation(
+ "patch",
+ "/v1/transcripts/{transcript_id}/speaker/merge",
+ {
+ onSuccess: (data, variables) => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ },
+ ).queryKey,
+ });
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
+ },
+ ).queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error merging speakers");
+ },
+ },
+ );
+}
+
+export function useMeetingAudioConsent() {
+ const { setError } = useError();
+
+ return $api.useMutation("post", "/v1/meetings/{meeting_id}/consent", {
+ onError: (error) => {
+ setError(error as Error, "There was an error recording consent");
+ },
+ });
+}
+
+export function useTranscriptWebRTC() {
+ const { setError } = useError();
+
+ return $api.useMutation(
+ "post",
+ "/v1/transcripts/{transcript_id}/record/webrtc",
+ {
+ onError: (error) => {
+ setError(error as Error, "There was an error with WebRTC connection");
+ },
+ },
+ );
+}
+
+export function useTranscriptCreate() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation("post", "/v1/transcripts", {
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["get", "/v1/transcripts/search"],
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error creating the transcript");
+ },
+ });
+}
+
+export function useRoomsCreateMeeting() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", {
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
+ });
+ },
+ onError: (error) => {
+ setError(error as Error, "There was an error creating the meeting");
+ },
+ });
+}
diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts
index 9169c694..f6e60513 100644
--- a/www/app/lib/auth.ts
+++ b/www/app/lib/auth.ts
@@ -1,157 +1,13 @@
-// import { kv } from "@vercel/kv";
-import Redlock, { ResourceLockedError } from "redlock";
-import { AuthOptions } from "next-auth";
-import AuthentikProvider from "next-auth/providers/authentik";
-import { JWT } from "next-auth/jwt";
-import { JWTWithAccessToken, CustomSession } from "./types";
-import Redis from "ioredis";
+export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
+// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
+export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
-const PRETIMEOUT = 60; // seconds before token expires to refresh it
-const DEFAULT_REDIS_KEY_TIMEOUT = 60 * 60 * 24 * 30; // 30 days (refresh token expires in 30 days)
-const kv = new Redis(process.env.KV_URL || "", {
- tls: {},
-});
-const redlock = new Redlock([kv], {});
+export const LOGIN_REQUIRED_PAGES = [
+ "/transcripts/[!new]",
+ "/browse(.*)",
+ "/rooms(.*)",
+];
-redlock.on("error", (error) => {
- if (error instanceof ResourceLockedError) {
- return;
- }
-
- // Log all other errors.
- console.error(error);
-});
-
-export const authOptions: AuthOptions = {
- providers: [
- AuthentikProvider({
- clientId: process.env.AUTHENTIK_CLIENT_ID as string,
- clientSecret: process.env.AUTHENTIK_CLIENT_SECRET as string,
- issuer: process.env.AUTHENTIK_ISSUER,
- authorization: {
- params: {
- scope: "openid email profile offline_access",
- },
- },
- }),
- ],
- session: {
- strategy: "jwt",
- },
- callbacks: {
- async jwt({ token, account, user }) {
- const extendedToken = token as JWTWithAccessToken;
- if (account && user) {
- // called only on first login
- // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
- const expiresAt = (account.expires_at as number) - PRETIMEOUT;
- const jwtToken = {
- ...extendedToken,
- accessToken: account.access_token,
- accessTokenExpires: expiresAt * 1000,
- refreshToken: account.refresh_token,
- };
- kv.set(
- `token:${jwtToken.sub}`,
- JSON.stringify(jwtToken),
- "EX",
- DEFAULT_REDIS_KEY_TIMEOUT,
- );
- return jwtToken;
- }
-
- if (Date.now() < extendedToken.accessTokenExpires) {
- return token;
- }
-
- // access token has expired, try to update it
- return await redisLockedrefreshAccessToken(token);
- },
- async session({ session, token }) {
- const extendedToken = token as JWTWithAccessToken;
- const customSession = session as CustomSession;
- customSession.accessToken = extendedToken.accessToken;
- customSession.accessTokenExpires = extendedToken.accessTokenExpires;
- customSession.error = extendedToken.error;
- customSession.user = {
- id: extendedToken.sub,
- name: extendedToken.name,
- email: extendedToken.email,
- };
- return customSession;
- },
- },
-};
-
-async function redisLockedrefreshAccessToken(token: JWT) {
- return await redlock.using(
- [token.sub as string, "jwt-refresh"],
- 5000,
- async () => {
- const redisToken = await kv.get(`token:${token.sub}`);
- const currentToken = JSON.parse(
- redisToken as string,
- ) as JWTWithAccessToken;
-
- // if there is multiple requests for the same token, it may already have been refreshed
- if (Date.now() < currentToken.accessTokenExpires) {
- return currentToken;
- }
-
- // now really do the request
- const newToken = await refreshAccessToken(currentToken);
- await kv.set(
- `token:${currentToken.sub}`,
- JSON.stringify(newToken),
- "EX",
- DEFAULT_REDIS_KEY_TIMEOUT,
- );
- return newToken;
- },
- );
-}
-
-async function refreshAccessToken(token: JWT): Promise {
- try {
- const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
-
- const options = {
- headers: {
- "Content-Type": "application/x-www-form-urlencoded",
- },
- body: new URLSearchParams({
- client_id: process.env.AUTHENTIK_CLIENT_ID as string,
- client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
- grant_type: "refresh_token",
- refresh_token: token.refreshToken as string,
- }).toString(),
- method: "POST",
- };
-
- const response = await fetch(url, options);
- if (!response.ok) {
- console.error(
- new Date().toISOString(),
- "Failed to refresh access token. Response status:",
- response.status,
- );
- const responseBody = await response.text();
- console.error(new Date().toISOString(), "Response body:", responseBody);
- throw new Error(`Failed to refresh access token: ${response.statusText}`);
- }
- const refreshedTokens = await response.json();
- return {
- ...token,
- accessToken: refreshedTokens.access_token,
- accessTokenExpires:
- Date.now() + (refreshedTokens.expires_in - PRETIMEOUT) * 1000,
- refreshToken: refreshedTokens.refresh_token,
- };
- } catch (error) {
- console.error("Error refreshing access token", error);
- return {
- ...token,
- error: "RefreshAccessTokenError",
- } as JWTWithAccessToken;
- }
-}
+export const PROTECTED_PAGES = new RegExp(
+ LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"),
+);
diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts
new file mode 100644
index 00000000..af93b274
--- /dev/null
+++ b/www/app/lib/authBackend.ts
@@ -0,0 +1,178 @@
+import { AuthOptions } from "next-auth";
+import AuthentikProvider from "next-auth/providers/authentik";
+import type { JWT } from "next-auth/jwt";
+import { JWTWithAccessToken, CustomSession } from "./types";
+import { assertExists, assertExistsAndNonEmptyString } from "./utils";
+import {
+ REFRESH_ACCESS_TOKEN_BEFORE,
+ REFRESH_ACCESS_TOKEN_ERROR,
+} from "./auth";
+import {
+ getTokenCache,
+ setTokenCache,
+ deleteTokenCache,
+} from "./redisTokenCache";
+import { tokenCacheRedis } from "./redisClient";
+import { isBuildPhase } from "./next";
+
+// REFRESH_ACCESS_TOKEN_BEFORE because refresh is based on access token expiration (imagine we cache it 30 days)
+const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
+
+const refreshLocks = new Map>();
+
+const CLIENT_ID = !isBuildPhase
+ ? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID)
+ : "noop";
+const CLIENT_SECRET = !isBuildPhase
+ ? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_SECRET)
+ : "noop";
+
+export const authOptions: AuthOptions = {
+ providers: [
+ AuthentikProvider({
+ clientId: CLIENT_ID,
+ clientSecret: CLIENT_SECRET,
+ issuer: process.env.AUTHENTIK_ISSUER,
+ authorization: {
+ params: {
+ scope: "openid email profile offline_access",
+ },
+ },
+ }),
+ ],
+ session: {
+ strategy: "jwt",
+ },
+ callbacks: {
+ async jwt({ token, account, user }) {
+ const KEY = `token:${token.sub}`;
+
+ if (account && user) {
+ // called only on first login
+ // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
+ const expiresAtS = assertExists(account.expires_at);
+ const expiresAtMs = expiresAtS * 1000;
+ if (!account.access_token) {
+ await deleteTokenCache(tokenCacheRedis, KEY);
+ } else {
+ const jwtToken: JWTWithAccessToken = {
+ ...token,
+ accessToken: account.access_token,
+ accessTokenExpires: expiresAtMs,
+ refreshToken: account.refresh_token,
+ };
+ await setTokenCache(tokenCacheRedis, KEY, {
+ token: jwtToken,
+ timestamp: Date.now(),
+ });
+ return jwtToken;
+ }
+ }
+
+ const currentToken = await getTokenCache(tokenCacheRedis, KEY);
+ if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
+ return currentToken.token;
+ }
+
+ // access token has expired, try to update it
+ return await lockedRefreshAccessToken(token);
+ },
+ async session({ session, token }) {
+ const extendedToken = token as JWTWithAccessToken;
+ return {
+ ...session,
+ accessToken: extendedToken.accessToken,
+ accessTokenExpires: extendedToken.accessTokenExpires,
+ error: extendedToken.error,
+ user: {
+ id: assertExists(extendedToken.sub),
+ name: extendedToken.name,
+ email: extendedToken.email,
+ },
+ } satisfies CustomSession;
+ },
+ },
+};
+
+async function lockedRefreshAccessToken(
+ token: JWT,
+): Promise {
+ const lockKey = `${token.sub}-refresh`;
+
+ const existingRefresh = refreshLocks.get(lockKey);
+ if (existingRefresh) {
+ return await existingRefresh;
+ }
+
+ const refreshPromise = (async () => {
+ try {
+ const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`);
+ if (cached) {
+ if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
+ await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
+ } else if (Date.now() < cached.token.accessTokenExpires) {
+ return cached.token;
+ }
+ }
+
+ const currentToken = cached?.token || (token as JWTWithAccessToken);
+ const newToken = await refreshAccessToken(currentToken);
+
+ await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
+ token: newToken,
+ timestamp: Date.now(),
+ });
+
+ return newToken;
+ } finally {
+ setTimeout(() => refreshLocks.delete(lockKey), 100);
+ }
+ })();
+
+ refreshLocks.set(lockKey, refreshPromise);
+ return refreshPromise;
+}
+
+async function refreshAccessToken(token: JWT): Promise {
+ try {
+ const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
+
+ const options = {
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: new URLSearchParams({
+ client_id: process.env.AUTHENTIK_CLIENT_ID as string,
+ client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
+ grant_type: "refresh_token",
+ refresh_token: token.refreshToken as string,
+ }).toString(),
+ method: "POST",
+ };
+
+ const response = await fetch(url, options);
+ if (!response.ok) {
+ console.error(
+ new Date().toISOString(),
+ "Failed to refresh access token. Response status:",
+ response.status,
+ );
+ const responseBody = await response.text();
+ console.error(new Date().toISOString(), "Response body:", responseBody);
+ throw new Error(`Failed to refresh access token: ${response.statusText}`);
+ }
+ const refreshedTokens = await response.json();
+ return {
+ ...token,
+ accessToken: refreshedTokens.access_token,
+ accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
+ refreshToken: refreshedTokens.refresh_token,
+ };
+ } catch (error) {
+ console.error("Error refreshing access token", error);
+ return {
+ ...token,
+ error: REFRESH_ACCESS_TOKEN_ERROR,
+ } as JWTWithAccessToken;
+ }
+}
diff --git a/www/app/lib/edgeConfig.ts b/www/app/lib/edgeConfig.ts
index 2e31e146..f234a2cf 100644
--- a/www/app/lib/edgeConfig.ts
+++ b/www/app/lib/edgeConfig.ts
@@ -1,5 +1,5 @@
import { get } from "@vercel/edge-config";
-import { isDevelopment } from "./utils";
+import { isBuildPhase } from "./next";
type EdgeConfig = {
[domainWithDash: string]: {
@@ -29,12 +29,18 @@ export function edgeDomainToKey(domain: string) {
// get edge config server-side (prefer DomainContext when available), domain is the hostname
export async function getConfig() {
- const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
-
if (process.env.NEXT_PUBLIC_ENV === "development") {
- return require("../../config").localConfig;
+ try {
+ return require("../../config").localConfig;
+ } catch (e) {
+ // next build() WILL try to execute the require above even if conditionally protected
+ // but thank god it at least runs catch{} block properly
+ if (!isBuildPhase) throw new Error(e);
+ return require("../../config-template").localConfig;
+ }
}
+ const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
let config = await get(edgeDomainToKey(domain));
if (typeof config !== "object") {
diff --git a/www/app/lib/next.ts b/www/app/lib/next.ts
new file mode 100644
index 00000000..91d88bd2
--- /dev/null
+++ b/www/app/lib/next.ts
@@ -0,0 +1,2 @@
+// next.js tries to run all the lib code during build phase; we don't always want it when e.g. we have connections initialized we don't want to have
+export const isBuildPhase = process.env.NEXT_PHASE?.includes("build");
diff --git a/www/app/lib/queryClient.tsx b/www/app/lib/queryClient.tsx
new file mode 100644
index 00000000..bd5946e0
--- /dev/null
+++ b/www/app/lib/queryClient.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { QueryClient } from "@tanstack/react-query";
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 60 * 1000, // 1 minute
+ gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
+ retry: 1,
+ refetchOnWindowFocus: false,
+ },
+ mutations: {
+ retry: 0,
+ },
+ },
+});
diff --git a/www/app/lib/redisClient.ts b/www/app/lib/redisClient.ts
new file mode 100644
index 00000000..1be36538
--- /dev/null
+++ b/www/app/lib/redisClient.ts
@@ -0,0 +1,46 @@
+import Redis from "ioredis";
+import { isBuildPhase } from "./next";
+
+export type RedisClient = Pick;
+
+const getRedisClient = (): RedisClient => {
+ const redisUrl = process.env.KV_URL;
+ if (!redisUrl) {
+ throw new Error("KV_URL environment variable is required");
+ }
+ const redis = new Redis(redisUrl, {
+ maxRetriesPerRequest: 3,
+ lazyConnect: true,
+ });
+
+ redis.on("error", (error) => {
+ console.error("Redis error:", error);
+ });
+
+ // not necessary but will indicate redis config errors by failfast at startup
+ // happens only once; after that connection is allowed to die and the lib is assumed to be able to restore it eventually
+ redis.connect().catch((e) => {
+ console.error("Failed to connect to Redis:", e);
+ process.exit(1);
+ });
+
+ return redis;
+};
+
+// next.js buildtime usage - we want to isolate next.js "build" time concepts here
+const noopClient: RedisClient = (() => {
+ const noopSetex: Redis["setex"] = async () => {
+ return "OK" as const;
+ };
+ const noopDel: Redis["del"] = async () => {
+ return 0;
+ };
+ return {
+ get: async () => {
+ return null;
+ },
+ setex: noopSetex,
+ del: noopDel,
+ };
+})();
+export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient();
diff --git a/www/app/lib/redisTokenCache.ts b/www/app/lib/redisTokenCache.ts
new file mode 100644
index 00000000..4fa4e304
--- /dev/null
+++ b/www/app/lib/redisTokenCache.ts
@@ -0,0 +1,61 @@
+import { z } from "zod";
+import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
+
+const TokenCacheEntrySchema = z.object({
+ token: z.object({
+ sub: z.string().optional(),
+ name: z.string().nullish(),
+ email: z.string().nullish(),
+ accessToken: z.string(),
+ accessTokenExpires: z.number(),
+ refreshToken: z.string().optional(),
+ error: z.string().optional(),
+ }),
+ timestamp: z.number(),
+});
+
+const TokenCacheEntryCodec = z.codec(z.string(), TokenCacheEntrySchema, {
+ decode: (jsonString) => {
+ const parsed = JSON.parse(jsonString);
+ return TokenCacheEntrySchema.parse(parsed);
+ },
+ encode: (value) => JSON.stringify(value),
+});
+
+export type TokenCacheEntry = z.infer;
+
+export type KV = {
+ get(key: string): Promise;
+ setex(key: string, seconds: number, value: string): Promise<"OK">;
+ del(key: string): Promise;
+};
+
+export async function getTokenCache(
+ redis: KV,
+ key: string,
+): Promise {
+ const data = await redis.get(key);
+ if (!data) return null;
+
+ try {
+ return TokenCacheEntryCodec.decode(data);
+ } catch (error) {
+ console.error("Invalid token cache data:", error);
+ await redis.del(key);
+ return null;
+ }
+}
+
+export async function setTokenCache(
+ redis: KV,
+ key: string,
+ value: TokenCacheEntry,
+): Promise {
+ const encodedValue = TokenCacheEntryCodec.encode(value);
+ const ttlSeconds = Math.floor(REFRESH_ACCESS_TOKEN_BEFORE / 1000);
+ await redis.setex(key, ttlSeconds, encodedValue);
+}
+
+export async function deleteTokenCache(redis: KV, key: string): Promise {
+ await redis.del(key);
+}
diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts
index 851ee5be..0576e186 100644
--- a/www/app/lib/types.ts
+++ b/www/app/lib/types.ts
@@ -1,10 +1,11 @@
-import { Session } from "next-auth";
-import { JWT } from "next-auth/jwt";
+import type { Session } from "next-auth";
+import type { JWT } from "next-auth/jwt";
+import { parseMaybeNonEmptyString } from "./utils";
export interface JWTWithAccessToken extends JWT {
accessToken: string;
accessTokenExpires: number;
- refreshToken: string;
+ refreshToken?: string;
error?: string;
}
@@ -12,9 +13,62 @@ export interface CustomSession extends Session {
accessToken: string;
accessTokenExpires: number;
error?: string;
- user: {
- id?: string;
- name?: string | null;
- email?: string | null;
+ user: Session["user"] & {
+ id: string;
};
}
+
+// assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there
+// but the assumption is crucial to auth working
+export const assertExtendedToken = (
+ t: T,
+): T & {
+ accessTokenExpires: number;
+ accessToken: string;
+} => {
+ if (
+ typeof (t as { accessTokenExpires: any }).accessTokenExpires === "number" &&
+ !isNaN((t as { accessTokenExpires: any }).accessTokenExpires) &&
+ typeof (
+ t as {
+ accessToken: any;
+ }
+ ).accessToken === "string" &&
+ parseMaybeNonEmptyString((t as { accessToken: any }).accessToken) !== null
+ ) {
+ return t as T & {
+ accessTokenExpires: number;
+ accessToken: string;
+ };
+ }
+ throw new Error("Token is not extended with access token");
+};
+
+export const assertExtendedTokenAndUserId = (
+ t: T,
+): T & {
+ accessTokenExpires: number;
+ accessToken: string;
+ user: U & {
+ id: string;
+ };
+} => {
+ const extendedToken = assertExtendedToken(t);
+ if (typeof (extendedToken.user as any)?.id === "string") {
+ return t as T & {
+ accessTokenExpires: number;
+ accessToken: string;
+ user: U & {
+ id: string;
+ };
+ };
+ }
+ throw new Error("Token is not extended with user id");
+};
+
+// best attempt to check the session is valid
+export const assertCustomSession = (s: S): CustomSession => {
+ const r = assertExtendedTokenAndUserId(s);
+ // no other checks for now
+ return r as CustomSession;
+};
diff --git a/www/app/lib/useApi.ts b/www/app/lib/useApi.ts
deleted file mode 100644
index 837ef84f..00000000
--- a/www/app/lib/useApi.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useSession, signOut } from "next-auth/react";
-import { useContext, useEffect, useState } from "react";
-import { DomainContext, featureEnabled } from "../domainContext";
-import { OpenApi, DefaultService } from "../api";
-import { CustomSession } from "./types";
-import useSessionStatus from "./useSessionStatus";
-import useSessionAccessToken from "./useSessionAccessToken";
-
-export default function useApi(): DefaultService | null {
- const api_url = useContext(DomainContext).api_url;
- const [api, setApi] = useState(null);
- const { isLoading, isAuthenticated } = useSessionStatus();
- const { accessToken, error } = useSessionAccessToken();
-
- if (!api_url) throw new Error("no API URL");
-
- useEffect(() => {
- if (error === "RefreshAccessTokenError") {
- signOut();
- }
- }, [error]);
-
- useEffect(() => {
- if (isLoading || (isAuthenticated && !accessToken)) {
- return;
- }
-
- const openApi = new OpenApi({
- BASE: api_url,
- TOKEN: accessToken || undefined,
- });
-
- setApi(openApi);
- }, [isLoading, isAuthenticated, accessToken]);
-
- return api?.default ?? null;
-}
diff --git a/www/app/lib/useLoginRequiredPages.ts b/www/app/lib/useLoginRequiredPages.ts
new file mode 100644
index 00000000..37ee96b1
--- /dev/null
+++ b/www/app/lib/useLoginRequiredPages.ts
@@ -0,0 +1,26 @@
+// for paths that are not supposed to be public
+import { PROTECTED_PAGES } from "./auth";
+import { usePathname } from "next/navigation";
+import { useAuth } from "./AuthProvider";
+import { useEffect } from "react";
+
+const HOME = "/" as const;
+
+export const useLoginRequiredPages = () => {
+ const pathname = usePathname();
+ const isProtected = PROTECTED_PAGES.test(pathname);
+ const auth = useAuth();
+ const isNotLoggedIn = auth.status === "unauthenticated";
+ // safety
+ const isLastDestination = pathname === HOME;
+ const shouldRedirect = isNotLoggedIn && isProtected && !isLastDestination;
+ useEffect(() => {
+ if (!shouldRedirect) return;
+ // on the backend, the redirect goes straight to the auth provider, but we don't have it because it's hidden inside next-auth middleware
+ // so we just "softly" lead the user to the main page
+ // warning: if HOME redirects somewhere else, we won't be protected by isLastDestination
+ window.location.href = HOME;
+ }, [shouldRedirect]);
+ // optionally save from blink, since window.location.href takes a bit of time
+ return shouldRedirect ? HOME : null;
+};
diff --git a/www/app/lib/useSessionAccessToken.ts b/www/app/lib/useSessionAccessToken.ts
deleted file mode 100644
index fc28c076..00000000
--- a/www/app/lib/useSessionAccessToken.ts
+++ /dev/null
@@ -1,42 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useSession as useNextAuthSession } from "next-auth/react";
-import { CustomSession } from "./types";
-
-export default function useSessionAccessToken() {
- const { data: session } = useNextAuthSession();
- const customSession = session as CustomSession;
- const naAccessToken = customSession?.accessToken;
- const naAccessTokenExpires = customSession?.accessTokenExpires;
- const naError = customSession?.error;
- const [accessToken, setAccessToken] = useState(null);
- const [accessTokenExpires, setAccessTokenExpires] = useState(
- null,
- );
- const [error, setError] = useState();
-
- useEffect(() => {
- if (naAccessToken !== accessToken) {
- setAccessToken(naAccessToken);
- }
- }, [naAccessToken]);
-
- useEffect(() => {
- if (naAccessTokenExpires !== accessTokenExpires) {
- setAccessTokenExpires(naAccessTokenExpires);
- }
- }, [naAccessTokenExpires]);
-
- useEffect(() => {
- if (naError !== error) {
- setError(naError);
- }
- }, [naError]);
-
- return {
- accessToken,
- accessTokenExpires,
- error,
- };
-}
diff --git a/www/app/lib/useSessionStatus.ts b/www/app/lib/useSessionStatus.ts
deleted file mode 100644
index 5629c025..00000000
--- a/www/app/lib/useSessionStatus.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useSession as useNextAuthSession } from "next-auth/react";
-import { Session } from "next-auth";
-
-export default function useSessionStatus() {
- const { status: naStatus } = useNextAuthSession();
- const [status, setStatus] = useState("loading");
-
- useEffect(() => {
- if (naStatus !== "loading" && naStatus !== status) {
- setStatus(naStatus);
- }
- }, [naStatus]);
-
- return {
- status,
- isLoading: status === "loading",
- isAuthenticated: status === "authenticated",
- };
-}
diff --git a/www/app/lib/useSessionUser.ts b/www/app/lib/useSessionUser.ts
deleted file mode 100644
index 2da299f5..00000000
--- a/www/app/lib/useSessionUser.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client";
-
-import { useState, useEffect } from "react";
-import { useSession as useNextAuthSession } from "next-auth/react";
-import { Session } from "next-auth";
-
-// user type with id, name, email
-export interface User {
- id?: string | null;
- name?: string | null;
- email?: string | null;
-}
-
-export default function useSessionUser() {
- const { data: session } = useNextAuthSession();
- const [user, setUser] = useState(null);
-
- useEffect(() => {
- if (!session?.user) {
- setUser(null);
- return;
- }
- if (JSON.stringify(session.user) !== JSON.stringify(user)) {
- setUser(session.user);
- }
- }, [session]);
-
- return {
- id: user?.id,
- name: user?.name,
- email: user?.email,
- };
-}
diff --git a/www/app/lib/useUserName.ts b/www/app/lib/useUserName.ts
new file mode 100644
index 00000000..80814281
--- /dev/null
+++ b/www/app/lib/useUserName.ts
@@ -0,0 +1,7 @@
+import { useAuth } from "./AuthProvider";
+
+export const useUserName = (): string | null | undefined => {
+ const auth = useAuth();
+ if (auth.status !== "authenticated") return undefined;
+ return auth.user?.name || null;
+};
diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts
index 80d0d91b..122ab234 100644
--- a/www/app/lib/utils.ts
+++ b/www/app/lib/utils.ts
@@ -137,9 +137,28 @@ export function extractDomain(url) {
}
}
-export function assertExists(value: T | null | undefined, err?: string): T {
+export type NonEmptyString = string & { __brand: "NonEmptyString" };
+export const parseMaybeNonEmptyString = (
+ s: string,
+ trim = true,
+): NonEmptyString | null => {
+ s = trim ? s.trim() : s;
+ return s.length > 0 ? (s as NonEmptyString) : null;
+};
+export const parseNonEmptyString = (s: string, trim = true): NonEmptyString =>
+ assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string");
+
+export const assertExists = (
+ value: T | null | undefined,
+ err?: string,
+): T => {
if (value === null || value === undefined) {
throw new Error(`Assertion failed: ${err ?? "value is null or undefined"}`);
}
return value;
-}
+};
+
+export const assertExistsAndNonEmptyString = (
+ value: string | null | undefined,
+): NonEmptyString =>
+ parseNonEmptyString(assertExists(value, "Expected non-empty string"));
diff --git a/www/app/providers.tsx b/www/app/providers.tsx
index f0f1ea52..2e3b78eb 100644
--- a/www/app/providers.tsx
+++ b/www/app/providers.tsx
@@ -6,16 +6,26 @@ import system from "./styles/theme";
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
import { Toaster } from "./components/ui/toaster";
import { NuqsAdapter } from "nuqs/adapters/next/app";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "./lib/queryClient";
+import { AuthProvider } from "./lib/AuthProvider";
+import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return (
-
-
- {children}
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
+
);
}
diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts
new file mode 100644
index 00000000..8a2cadb0
--- /dev/null
+++ b/www/app/reflector-api.d.ts
@@ -0,0 +1,2330 @@
+/**
+ * This file was auto-generated by openapi-typescript.
+ * Do not make direct changes to the file.
+ */
+
+export interface paths {
+ "/metrics": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Metrics
+ * @description Endpoint that serves Prometheus metrics.
+ */
+ get: operations["metrics"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/meetings/{meeting_id}/consent": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Meeting Audio Consent */
+ post: operations["v1_meeting_audio_consent"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Rooms List */
+ get: operations["v1_rooms_list"];
+ put?: never;
+ /** Rooms Create */
+ post: operations["v1_rooms_create"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms/{room_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Rooms Get */
+ get: operations["v1_rooms_get"];
+ put?: never;
+ post?: never;
+ /** Rooms Delete */
+ delete: operations["v1_rooms_delete"];
+ options?: never;
+ head?: never;
+ /** Rooms Update */
+ patch: operations["v1_rooms_update"];
+ trace?: never;
+ };
+ "/v1/rooms/{room_name}/meeting": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Rooms Create Meeting */
+ post: operations["v1_rooms_create_meeting"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms/{room_id}/webhook/test": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Rooms Test Webhook
+ * @description Test webhook configuration by sending a sample payload.
+ */
+ post: operations["v1_rooms_test_webhook"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcripts List */
+ get: operations["v1_transcripts_list"];
+ put?: never;
+ /** Transcripts Create */
+ post: operations["v1_transcripts_create"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/search": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Transcripts Search
+ * @description Full-text search across transcript titles and content.
+ */
+ get: operations["v1_transcripts_search"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get */
+ get: operations["v1_transcript_get"];
+ put?: never;
+ post?: never;
+ /** Transcript Delete */
+ delete: operations["v1_transcript_delete"];
+ options?: never;
+ head?: never;
+ /** Transcript Update */
+ patch: operations["v1_transcript_update"];
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/topics": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get Topics */
+ get: operations["v1_transcript_get_topics"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/topics/with-words": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get Topics With Words */
+ get: operations["v1_transcript_get_topics_with_words"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/topics/{topic_id}/words-per-speaker": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get Topics With Words Per Speaker */
+ get: operations["v1_transcript_get_topics_with_words_per_speaker"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/zulip": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Transcript Post To Zulip */
+ post: operations["v1_transcript_post_to_zulip"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/audio/mp3": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get Audio Mp3 */
+ get: operations["v1_transcript_get_audio_mp3"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ /** Transcript Get Audio Mp3 */
+ head: operations["v1_transcript_head_audio_mp3"];
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/audio/waveform": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get Audio Waveform */
+ get: operations["v1_transcript_get_audio_waveform"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/participants": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get Participants */
+ get: operations["v1_transcript_get_participants"];
+ put?: never;
+ /** Transcript Add Participant */
+ post: operations["v1_transcript_add_participant"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/participants/{participant_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get Participant */
+ get: operations["v1_transcript_get_participant"];
+ put?: never;
+ post?: never;
+ /** Transcript Delete Participant */
+ delete: operations["v1_transcript_delete_participant"];
+ options?: never;
+ head?: never;
+ /** Transcript Update Participant */
+ patch: operations["v1_transcript_update_participant"];
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/speaker/assign": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /** Transcript Assign Speaker */
+ patch: operations["v1_transcript_assign_speaker"];
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/speaker/merge": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /** Transcript Merge Speaker */
+ patch: operations["v1_transcript_merge_speaker"];
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/record/upload": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Transcript Record Upload */
+ post: operations["v1_transcript_record_upload"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Transcript Get Websocket Events */
+ get: operations["v1_transcript_get_websocket_events"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/record/webrtc": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Transcript Record Webrtc */
+ post: operations["v1_transcript_record_webrtc"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/transcripts/{transcript_id}/process": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Transcript Process */
+ post: operations["v1_transcript_process"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/me": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** User Me */
+ get: operations["v1_user_me"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/zulip/streams": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Zulip Get Streams
+ * @description Get all Zulip streams.
+ */
+ get: operations["v1_zulip_get_streams"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/zulip/streams/{stream_id}/topics": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Zulip Get Topics
+ * @description Get all topics for a specific Zulip stream.
+ */
+ get: operations["v1_zulip_get_topics"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/whereby": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Whereby Webhook */
+ post: operations["v1_whereby_webhook"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+}
+export type webhooks = Record;
+export interface components {
+ schemas: {
+ /** AudioWaveform */
+ AudioWaveform: {
+ /** Data */
+ data: number[];
+ };
+ /** Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post */
+ Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post: {
+ /**
+ * Chunk
+ * Format: binary
+ */
+ chunk: string;
+ };
+ /** CreateParticipant */
+ CreateParticipant: {
+ /** Speaker */
+ speaker?: number | null;
+ /** Name */
+ name: string;
+ };
+ /** CreateRoom */
+ CreateRoom: {
+ /** Name */
+ name: string;
+ /** Zulip Auto Post */
+ zulip_auto_post: boolean;
+ /** Zulip Stream */
+ zulip_stream: string;
+ /** Zulip Topic */
+ zulip_topic: string;
+ /** Is Locked */
+ is_locked: boolean;
+ /** Room Mode */
+ room_mode: string;
+ /** Recording Type */
+ recording_type: string;
+ /** Recording Trigger */
+ recording_trigger: string;
+ /** Is Shared */
+ is_shared: boolean;
+ /** Webhook Url */
+ webhook_url: string;
+ /** Webhook Secret */
+ webhook_secret: string;
+ };
+ /** CreateTranscript */
+ CreateTranscript: {
+ /** Name */
+ name: string;
+ /**
+ * Source Language
+ * @default en
+ */
+ source_language: string;
+ /**
+ * Target Language
+ * @default en
+ */
+ target_language: string;
+ source_kind?: components["schemas"]["SourceKind"] | null;
+ };
+ /** 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 */
+ 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;
+ };
+ /** GetTranscriptSegmentTopic */
+ GetTranscriptSegmentTopic: {
+ /** Text */
+ text: string;
+ /** Start */
+ start: number;
+ /** Speaker */
+ speaker: number;
+ };
+ /** GetTranscriptTopic */
+ GetTranscriptTopic: {
+ /** Id */
+ id: string;
+ /** Title */
+ title: string;
+ /** Summary */
+ summary: string;
+ /** Timestamp */
+ timestamp: number;
+ /** Duration */
+ duration: number | null;
+ /** Transcript */
+ transcript: string;
+ /**
+ * Segments
+ * @default []
+ */
+ segments: components["schemas"]["GetTranscriptSegmentTopic"][];
+ };
+ /** GetTranscriptTopicWithWords */
+ GetTranscriptTopicWithWords: {
+ /** Id */
+ id: string;
+ /** Title */
+ title: string;
+ /** Summary */
+ summary: string;
+ /** Timestamp */
+ timestamp: number;
+ /** Duration */
+ duration: number | null;
+ /** Transcript */
+ transcript: string;
+ /**
+ * Segments
+ * @default []
+ */
+ segments: components["schemas"]["GetTranscriptSegmentTopic"][];
+ /**
+ * Words
+ * @default []
+ */
+ words: components["schemas"]["Word"][];
+ };
+ /** GetTranscriptTopicWithWordsPerSpeaker */
+ GetTranscriptTopicWithWordsPerSpeaker: {
+ /** Id */
+ id: string;
+ /** Title */
+ title: string;
+ /** Summary */
+ summary: string;
+ /** Timestamp */
+ timestamp: number;
+ /** Duration */
+ duration: number | null;
+ /** Transcript */
+ transcript: string;
+ /**
+ * Segments
+ * @default []
+ */
+ segments: components["schemas"]["GetTranscriptSegmentTopic"][];
+ /**
+ * Words Per Speaker
+ * @default []
+ */
+ words_per_speaker: components["schemas"]["SpeakerWords"][];
+ };
+ /** HTTPValidationError */
+ HTTPValidationError: {
+ /** Detail */
+ detail?: components["schemas"]["ValidationError"][];
+ };
+ /** Meeting */
+ Meeting: {
+ /** Id */
+ id: string;
+ /** Room Name */
+ room_name: string;
+ /** Room Url */
+ room_url: string;
+ /** Host Room Url */
+ host_room_url: string;
+ /**
+ * Start Date
+ * Format: date-time
+ */
+ start_date: string;
+ /**
+ * End Date
+ * Format: date-time
+ */
+ end_date: string;
+ /**
+ * Recording Type
+ * @default cloud
+ * @enum {string}
+ */
+ recording_type: "none" | "local" | "cloud";
+ };
+ /** MeetingConsentRequest */
+ MeetingConsentRequest: {
+ /** Consent Given */
+ consent_given: boolean;
+ };
+ /** Page[GetTranscriptMinimal] */
+ Page_GetTranscriptMinimal_: {
+ /** Items */
+ items: components["schemas"]["GetTranscriptMinimal"][];
+ /** Total */
+ total?: number | null;
+ /** Page */
+ page: number | null;
+ /** Size */
+ size: number | null;
+ /** Pages */
+ pages?: number | null;
+ };
+ /** Page[RoomDetails] */
+ Page_RoomDetails_: {
+ /** Items */
+ items: components["schemas"]["RoomDetails"][];
+ /** Total */
+ total?: number | null;
+ /** Page */
+ page: number | null;
+ /** Size */
+ size: number | null;
+ /** Pages */
+ pages?: number | null;
+ };
+ /** Participant */
+ Participant: {
+ /** Id */
+ id: string;
+ /** Speaker */
+ speaker: number | null;
+ /** Name */
+ name: string;
+ };
+ /** Room */
+ Room: {
+ /** Id */
+ id: string;
+ /** Name */
+ name: string;
+ /** User Id */
+ user_id: string;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /** Zulip Auto Post */
+ zulip_auto_post: boolean;
+ /** Zulip Stream */
+ zulip_stream: string;
+ /** Zulip Topic */
+ zulip_topic: string;
+ /** Is Locked */
+ is_locked: boolean;
+ /** Room Mode */
+ room_mode: string;
+ /** Recording Type */
+ recording_type: string;
+ /** Recording Trigger */
+ recording_trigger: string;
+ /** Is Shared */
+ is_shared: boolean;
+ };
+ /** RoomDetails */
+ RoomDetails: {
+ /** Id */
+ id: string;
+ /** Name */
+ name: string;
+ /** User Id */
+ user_id: string;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /** Zulip Auto Post */
+ zulip_auto_post: boolean;
+ /** Zulip Stream */
+ zulip_stream: string;
+ /** Zulip Topic */
+ zulip_topic: string;
+ /** Is Locked */
+ is_locked: boolean;
+ /** Room Mode */
+ room_mode: string;
+ /** Recording Type */
+ recording_type: string;
+ /** Recording Trigger */
+ recording_trigger: string;
+ /** Is Shared */
+ is_shared: boolean;
+ /** Webhook Url */
+ webhook_url: string | null;
+ /** Webhook Secret */
+ webhook_secret: string | null;
+ };
+ /** RtcOffer */
+ RtcOffer: {
+ /** Sdp */
+ sdp: string;
+ /** Type */
+ type: string;
+ };
+ /** SearchResponse */
+ SearchResponse: {
+ /** Results */
+ results: components["schemas"]["SearchResult"][];
+ /**
+ * Total
+ * @description Total number of search results
+ */
+ total: number;
+ /** Query */
+ query?: string | null;
+ /**
+ * Limit
+ * @description Results per page
+ */
+ limit: number;
+ /**
+ * Offset
+ * @description Number of results to skip
+ */
+ offset: number;
+ };
+ /**
+ * SearchResult
+ * @description Public search result model with computed fields.
+ */
+ SearchResult: {
+ /** Id */
+ id: string;
+ /** Title */
+ title?: string | null;
+ /** User Id */
+ user_id?: string | null;
+ /** Room Id */
+ room_id?: string | null;
+ /** Room Name */
+ room_name?: string | null;
+ source_kind: components["schemas"]["SourceKind"];
+ /** Created At */
+ created_at: string;
+ /** Status */
+ status: string;
+ /** Rank */
+ rank: number;
+ /**
+ * Duration
+ * @description Duration in seconds
+ */
+ duration: number | null;
+ /**
+ * Search Snippets
+ * @description Text snippets around search matches
+ */
+ search_snippets: string[];
+ /**
+ * Total Match Count
+ * @description Total number of matches found in the transcript
+ * @default 0
+ */
+ total_match_count: number;
+ };
+ /**
+ * SourceKind
+ * @enum {string}
+ */
+ SourceKind: "room" | "live" | "file";
+ /** SpeakerAssignment */
+ SpeakerAssignment: {
+ /** Speaker */
+ speaker?: number | null;
+ /** Participant */
+ participant?: string | null;
+ /** Timestamp From */
+ timestamp_from: number;
+ /** Timestamp To */
+ timestamp_to: number;
+ };
+ /** SpeakerAssignmentStatus */
+ SpeakerAssignmentStatus: {
+ /** Status */
+ status: string;
+ };
+ /** SpeakerMerge */
+ SpeakerMerge: {
+ /** Speaker From */
+ speaker_from: number;
+ /** Speaker To */
+ speaker_to: number;
+ };
+ /** SpeakerWords */
+ SpeakerWords: {
+ /** Speaker */
+ speaker: number;
+ /** Words */
+ words: components["schemas"]["Word"][];
+ };
+ /** Stream */
+ Stream: {
+ /** Stream Id */
+ stream_id: number;
+ /** Name */
+ name: string;
+ };
+ /** Topic */
+ Topic: {
+ /** Name */
+ name: string;
+ };
+ /** TranscriptParticipant */
+ TranscriptParticipant: {
+ /** Id */
+ id?: string;
+ /** Speaker */
+ speaker: number | null;
+ /** Name */
+ name: string;
+ };
+ /** UpdateParticipant */
+ UpdateParticipant: {
+ /** Speaker */
+ speaker?: number | null;
+ /** Name */
+ name?: string | null;
+ };
+ /** UpdateRoom */
+ UpdateRoom: {
+ /** Name */
+ name: string;
+ /** Zulip Auto Post */
+ zulip_auto_post: boolean;
+ /** Zulip Stream */
+ zulip_stream: string;
+ /** Zulip Topic */
+ zulip_topic: string;
+ /** Is Locked */
+ is_locked: boolean;
+ /** Room Mode */
+ room_mode: string;
+ /** Recording Type */
+ recording_type: string;
+ /** Recording Trigger */
+ recording_trigger: string;
+ /** Is Shared */
+ is_shared: boolean;
+ /** Webhook Url */
+ webhook_url: string;
+ /** Webhook Secret */
+ webhook_secret: string;
+ };
+ /** UpdateTranscript */
+ UpdateTranscript: {
+ /** Name */
+ name?: string | null;
+ /** Locked */
+ locked?: boolean | null;
+ /** Title */
+ title?: string | null;
+ /** Short Summary */
+ short_summary?: string | null;
+ /** Long Summary */
+ long_summary?: string | null;
+ /** Share Mode */
+ share_mode?: ("public" | "semi-private" | "private") | null;
+ /** Participants */
+ participants?: components["schemas"]["TranscriptParticipant"][] | null;
+ /** Reviewed */
+ reviewed?: boolean | null;
+ /** Audio Deleted */
+ audio_deleted?: boolean | null;
+ };
+ /** UserInfo */
+ UserInfo: {
+ /** Sub */
+ sub: string;
+ /** Email */
+ email: string | null;
+ /** Email Verified */
+ email_verified: boolean | null;
+ };
+ /** ValidationError */
+ ValidationError: {
+ /** Location */
+ loc: (string | number)[];
+ /** Message */
+ msg: string;
+ /** Error Type */
+ type: string;
+ };
+ /** WebhookTestResult */
+ WebhookTestResult: {
+ /** Success */
+ success: boolean;
+ /**
+ * Message
+ * @default
+ */
+ message: string;
+ /**
+ * Error
+ * @default
+ */
+ error: string;
+ /** Status Code */
+ status_code?: number | null;
+ /** Response Preview */
+ response_preview?: string | null;
+ };
+ /** WherebyWebhookEvent */
+ WherebyWebhookEvent: {
+ /** Apiversion */
+ apiVersion: string;
+ /** Id */
+ id: string;
+ /**
+ * Createdat
+ * Format: date-time
+ */
+ createdAt: string;
+ /** Type */
+ type: string;
+ /** Data */
+ data: {
+ [key: string]: unknown;
+ };
+ };
+ /** Word */
+ Word: {
+ /** Text */
+ text: string;
+ /**
+ * Start
+ * @description Time in seconds with float part
+ */
+ start: number;
+ /**
+ * End
+ * @description Time in seconds with float part
+ */
+ end: number;
+ /**
+ * Speaker
+ * @default 0
+ */
+ speaker: number;
+ };
+ };
+ responses: never;
+ parameters: never;
+ requestBodies: never;
+ headers: never;
+ pathItems: never;
+}
+export type $defs = Record;
+export interface operations {
+ metrics: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ };
+ };
+ v1_meeting_audio_consent: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ meeting_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["MeetingConsentRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_list: {
+ parameters: {
+ query?: {
+ /** @description Page number */
+ page?: number;
+ /** @description Page size */
+ size?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Page_RoomDetails_"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_create: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateRoom"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Room"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RoomDetails"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["DeletionStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_update: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateRoom"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RoomDetails"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_create_meeting: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Meeting"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_test_webhook: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["WebhookTestResult"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcripts_list: {
+ parameters: {
+ query?: {
+ source_kind?: components["schemas"]["SourceKind"] | null;
+ room_id?: string | null;
+ search_term?: string | null;
+ /** @description Page number */
+ page?: number;
+ /** @description Page size */
+ size?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Page_GetTranscriptMinimal_"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcripts_create: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateTranscript"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GetTranscript"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcripts_search: {
+ parameters: {
+ query: {
+ /** @description Search query text */
+ q: string;
+ /** @description Results per page */
+ limit?: number;
+ /** @description Number of results to skip */
+ offset?: number;
+ room_id?: string | null;
+ source_kind?: components["schemas"]["SourceKind"] | null;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SearchResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GetTranscript"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["DeletionStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_update: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateTranscript"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GetTranscript"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get_topics: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GetTranscriptTopic"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get_topics_with_words: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GetTranscriptTopicWithWords"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get_topics_with_words_per_speaker: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ topic_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["GetTranscriptTopicWithWordsPerSpeaker"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_post_to_zulip: {
+ parameters: {
+ query: {
+ stream: string;
+ topic: string;
+ include_topics: boolean;
+ };
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get_audio_mp3: {
+ parameters: {
+ query?: {
+ token?: string | null;
+ };
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_head_audio_mp3: {
+ parameters: {
+ query?: {
+ token?: string | null;
+ };
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get_audio_waveform: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["AudioWaveform"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get_participants: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Participant"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_add_participant: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateParticipant"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Participant"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get_participant: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ participant_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Participant"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_delete_participant: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ participant_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["DeletionStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_update_participant: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ participant_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UpdateParticipant"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Participant"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_assign_speaker: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["SpeakerAssignment"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SpeakerAssignmentStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_merge_speaker: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["SpeakerMerge"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["SpeakerAssignmentStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_record_upload: {
+ parameters: {
+ query: {
+ chunk_number: number;
+ total_chunks: number;
+ };
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "multipart/form-data": components["schemas"]["Body_transcript_record_upload_v1_transcripts__transcript_id__record_upload_post"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_get_websocket_events: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_record_webrtc: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["RtcOffer"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_transcript_process: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ transcript_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_user_me: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UserInfo"] | null;
+ };
+ };
+ };
+ };
+ v1_zulip_get_streams: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Stream"][];
+ };
+ };
+ };
+ };
+ v1_zulip_get_topics: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ stream_id: number;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Topic"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_whereby_webhook: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["WherebyWebhookEvent"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+}
diff --git a/www/jest.config.js b/www/jest.config.js
new file mode 100644
index 00000000..d2f3247b
--- /dev/null
+++ b/www/jest.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ preset: "ts-jest",
+ testEnvironment: "node",
+ roots: ["/app"],
+ testMatch: ["**/__tests__/**/*.test.ts"],
+ collectCoverage: true,
+ collectCoverageFrom: ["app/**/*.ts", "!app/**/*.d.ts"],
+};
diff --git a/www/middleware.ts b/www/middleware.ts
index 39145220..2b60d715 100644
--- a/www/middleware.ts
+++ b/www/middleware.ts
@@ -1,16 +1,7 @@
import { withAuth } from "next-auth/middleware";
import { getConfig } from "./app/lib/edgeConfig";
import { NextResponse } from "next/server";
-
-const LOGIN_REQUIRED_PAGES = [
- "/transcripts/[!new]",
- "/browse(.*)",
- "/rooms(.*)",
-];
-
-const PROTECTED_PAGES = new RegExp(
- LOGIN_REQUIRED_PAGES.map((page) => `^${page}$`).join("|"),
-);
+import { PROTECTED_PAGES } from "./app/lib/auth";
export const config = {
matcher: [
diff --git a/www/next.config.js b/www/next.config.js
index e37d5402..bbc3f710 100644
--- a/www/next.config.js
+++ b/www/next.config.js
@@ -2,6 +2,9 @@
const nextConfig = {
output: "standalone",
experimental: { esmExternals: "loose" },
+ env: {
+ IS_CI: process.env.IS_CI,
+ },
};
module.exports = nextConfig;
diff --git a/www/openapi-ts.config.ts b/www/openapi-ts.config.ts
deleted file mode 100644
index 9304b8f7..00000000
--- a/www/openapi-ts.config.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { defineConfig } from "@hey-api/openapi-ts";
-
-export default defineConfig({
- client: "axios",
- name: "OpenApi",
- input: "http://127.0.0.1:1250/openapi.json",
- output: {
- path: "./app/api",
- format: "prettier",
- },
- services: {
- asClass: true,
- },
-});
diff --git a/www/package.json b/www/package.json
index 482a29f6..b7511147 100644
--- a/www/package.json
+++ b/www/package.json
@@ -8,7 +8,8 @@
"start": "next start",
"lint": "next lint",
"format": "prettier --write .",
- "openapi": "openapi-ts"
+ "openapi": "openapi-typescript http://127.0.0.1:1250/openapi.json -o ./app/reflector-api.d.ts",
+ "test": "jest"
},
"dependencies": {
"@chakra-ui/react": "^3.24.2",
@@ -17,21 +18,24 @@
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@sentry/nextjs": "^7.77.0",
+ "@tanstack/react-query": "^5.85.9",
+ "@types/ioredis": "^5.0.0",
"@vercel/edge-config": "^0.4.1",
- "@vercel/kv": "^2.0.0",
"@whereby.com/browser-sdk": "^3.3.4",
"autoprefixer": "10.4.20",
"axios": "^1.8.2",
"eslint": "^9.33.0",
"eslint-config-next": "^14.2.31",
"fontawesome": "^5.6.3",
- "ioredis": "^5.4.1",
+ "ioredis": "^5.7.0",
"jest-worker": "^29.6.2",
"lucide-react": "^0.525.0",
"next": "^14.2.30",
"next-auth": "^4.24.7",
"next-themes": "^0.4.6",
"nuqs": "^2.4.3",
+ "openapi-fetch": "^0.14.0",
+ "openapi-react-query": "^0.5.0",
"postcss": "8.4.31",
"prop-types": "^15.8.1",
"react": "^18.2.0",
@@ -41,21 +45,24 @@
"react-markdown": "^9.0.0",
"react-qr-code": "^2.0.12",
"react-select-search": "^4.1.7",
- "redlock": "^5.0.0-beta.2",
"sass": "^1.63.6",
"simple-peer": "^9.11.1",
"tailwindcss": "^3.3.2",
"typescript": "^5.1.6",
- "wavesurfer.js": "^7.4.2"
+ "wavesurfer.js": "^7.4.2",
+ "zod": "^4.1.5"
},
"main": "index.js",
"repository": "https://github.com/Monadical-SAS/reflector-ui.git",
"author": "Andreas ",
"license": "All Rights Reserved",
"devDependencies": {
- "@hey-api/openapi-ts": "^0.48.0",
+ "@types/jest": "^30.0.0",
"@types/react": "18.2.20",
+ "jest": "^30.1.3",
+ "openapi-typescript": "^7.9.1",
"prettier": "^3.0.0",
+ "ts-jest": "^29.4.1",
"vercel": "^37.3.0"
},
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml
index 55aef9c8..14b42c55 100644
--- a/www/pnpm-lock.yaml
+++ b/www/pnpm-lock.yaml
@@ -24,13 +24,16 @@ importers:
version: 0.2.3(@fortawesome/fontawesome-svg-core@6.7.2)(react@18.3.1)
"@sentry/nextjs":
specifier: ^7.77.0
- version: 7.120.4(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
+ version: 7.120.4(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
+ "@tanstack/react-query":
+ specifier: ^5.85.9
+ version: 5.85.9(react@18.3.1)
+ "@types/ioredis":
+ specifier: ^5.0.0
+ version: 5.0.0
"@vercel/edge-config":
specifier: ^0.4.1
version: 0.4.1
- "@vercel/kv":
- specifier: ^2.0.0
- version: 2.0.0
"@whereby.com/browser-sdk":
specifier: ^3.3.4
version: 3.13.1(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -50,7 +53,7 @@ importers:
specifier: ^5.6.3
version: 5.6.3
ioredis:
- specifier: ^5.4.1
+ specifier: ^5.7.0
version: 5.7.0
jest-worker:
specifier: ^29.6.2
@@ -60,16 +63,22 @@ importers:
version: 0.525.0(react@18.3.1)
next:
specifier: ^14.2.30
- version: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ version: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
next-auth:
specifier: ^4.24.7
- version: 4.24.11(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 4.24.11(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nuqs:
specifier: ^2.4.3
- version: 2.4.3(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
+ version: 2.4.3(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
+ openapi-fetch:
+ specifier: ^0.14.0
+ version: 0.14.0
+ openapi-react-query:
+ specifier: ^0.5.0
+ version: 0.5.0(@tanstack/react-query@5.85.9(react@18.3.1))(openapi-fetch@0.14.0)
postcss:
specifier: 8.4.31
version: 8.4.31
@@ -97,9 +106,6 @@ importers:
react-select-search:
specifier: ^4.1.7
version: 4.1.8(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
- redlock:
- specifier: ^5.0.0-beta.2
- version: 5.0.0-beta.2
sass:
specifier: ^1.63.6
version: 1.90.0
@@ -115,16 +121,28 @@ importers:
wavesurfer.js:
specifier: ^7.4.2
version: 7.10.1
+ zod:
+ specifier: ^4.1.5
+ version: 4.1.5
devDependencies:
- "@hey-api/openapi-ts":
- specifier: ^0.48.0
- version: 0.48.3(typescript@5.9.2)
+ "@types/jest":
+ specifier: ^30.0.0
+ version: 30.0.0
"@types/react":
specifier: 18.2.20
version: 18.2.20
+ jest:
+ specifier: ^30.1.3
+ version: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ openapi-typescript:
+ specifier: ^7.9.1
+ version: 7.9.1(typescript@5.9.2)
prettier:
specifier: ^3.0.0
version: 3.6.2
+ ts-jest:
+ specifier: ^29.4.1
+ version: 29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)))(typescript@5.9.2)
vercel:
specifier: ^37.3.0
version: 37.14.0
@@ -137,12 +155,12 @@ packages:
}
engines: { node: ">=10" }
- "@apidevtools/json-schema-ref-parser@11.6.4":
+ "@ampproject/remapping@2.3.0":
resolution:
{
- integrity: sha512-9K6xOqeevacvweLGik6LnZCb1fBtCOSIWQs8d096XGeqoLKC33UVMGz9+77Gw44KvbH4pKcQPWo4ZpxkXYj05w==,
+ integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==,
}
- engines: { node: ">= 16" }
+ engines: { node: ">=6.0.0" }
"@ark-ui/react@5.18.2":
resolution:
@@ -160,6 +178,20 @@ packages:
}
engines: { node: ">=6.9.0" }
+ "@babel/compat-data@7.28.0":
+ resolution:
+ {
+ integrity: sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==,
+ }
+ engines: { node: ">=6.9.0" }
+
+ "@babel/core@7.28.3":
+ resolution:
+ {
+ integrity: sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==,
+ }
+ engines: { node: ">=6.9.0" }
+
"@babel/generator@7.28.0":
resolution:
{
@@ -167,6 +199,20 @@ packages:
}
engines: { node: ">=6.9.0" }
+ "@babel/generator@7.28.3":
+ resolution:
+ {
+ integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==,
+ }
+ engines: { node: ">=6.9.0" }
+
+ "@babel/helper-compilation-targets@7.27.2":
+ resolution:
+ {
+ integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==,
+ }
+ engines: { node: ">=6.9.0" }
+
"@babel/helper-globals@7.28.0":
resolution:
{
@@ -181,6 +227,22 @@ packages:
}
engines: { node: ">=6.9.0" }
+ "@babel/helper-module-transforms@7.28.3":
+ resolution:
+ {
+ integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==,
+ }
+ engines: { node: ">=6.9.0" }
+ peerDependencies:
+ "@babel/core": ^7.0.0
+
+ "@babel/helper-plugin-utils@7.27.1":
+ resolution:
+ {
+ integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==,
+ }
+ engines: { node: ">=6.9.0" }
+
"@babel/helper-string-parser@7.27.1":
resolution:
{
@@ -195,6 +257,20 @@ packages:
}
engines: { node: ">=6.9.0" }
+ "@babel/helper-validator-option@7.27.1":
+ resolution:
+ {
+ integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==,
+ }
+ engines: { node: ">=6.9.0" }
+
+ "@babel/helpers@7.28.3":
+ resolution:
+ {
+ integrity: sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==,
+ }
+ engines: { node: ">=6.9.0" }
+
"@babel/parser@7.28.0":
resolution:
{
@@ -203,6 +279,156 @@ packages:
engines: { node: ">=6.0.0" }
hasBin: true
+ "@babel/parser@7.28.3":
+ resolution:
+ {
+ integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==,
+ }
+ engines: { node: ">=6.0.0" }
+ hasBin: true
+
+ "@babel/plugin-syntax-async-generators@7.8.4":
+ resolution:
+ {
+ integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-bigint@7.8.3":
+ resolution:
+ {
+ integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-class-properties@7.12.13":
+ resolution:
+ {
+ integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-class-static-block@7.14.5":
+ resolution:
+ {
+ integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==,
+ }
+ engines: { node: ">=6.9.0" }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-import-attributes@7.27.1":
+ resolution:
+ {
+ integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==,
+ }
+ engines: { node: ">=6.9.0" }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-import-meta@7.10.4":
+ resolution:
+ {
+ integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-json-strings@7.8.3":
+ resolution:
+ {
+ integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-jsx@7.27.1":
+ resolution:
+ {
+ integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==,
+ }
+ engines: { node: ">=6.9.0" }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-logical-assignment-operators@7.10.4":
+ resolution:
+ {
+ integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3":
+ resolution:
+ {
+ integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-numeric-separator@7.10.4":
+ resolution:
+ {
+ integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-object-rest-spread@7.8.3":
+ resolution:
+ {
+ integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-optional-catch-binding@7.8.3":
+ resolution:
+ {
+ integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-optional-chaining@7.8.3":
+ resolution:
+ {
+ integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-private-property-in-object@7.14.5":
+ resolution:
+ {
+ integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==,
+ }
+ engines: { node: ">=6.9.0" }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-top-level-await@7.14.5":
+ resolution:
+ {
+ integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==,
+ }
+ engines: { node: ">=6.9.0" }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
+ "@babel/plugin-syntax-typescript@7.27.1":
+ resolution:
+ {
+ integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==,
+ }
+ engines: { node: ">=6.9.0" }
+ peerDependencies:
+ "@babel/core": ^7.0.0-0
+
"@babel/runtime@7.28.2":
resolution:
{
@@ -224,6 +450,13 @@ packages:
}
engines: { node: ">=6.9.0" }
+ "@babel/traverse@7.28.3":
+ resolution:
+ {
+ integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==,
+ }
+ engines: { node: ">=6.9.0" }
+
"@babel/types@7.28.2":
resolution:
{
@@ -231,6 +464,12 @@ packages:
}
engines: { node: ">=6.9.0" }
+ "@bcoe/v8-coverage@0.2.3":
+ resolution:
+ {
+ integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==,
+ }
+
"@chakra-ui/react@3.24.2":
resolution:
{
@@ -516,16 +755,6 @@ packages:
"@fortawesome/fontawesome-svg-core": ~1 || ~6 || ~7
react: ^16.3 || ^17.0.0 || ^18.0.0 || ^19.0.0
- "@hey-api/openapi-ts@0.48.3":
- resolution:
- {
- integrity: sha512-R53Nr4Gicz77icS+RiH0fwHa9A0uFPtzsjC8SBaGwtOel5ZyxeBbayWE6HhE789hp3dok9pegwWncwwOrr4WFA==,
- }
- engines: { node: ^18.0.0 || >=20.0.0 }
- hasBin: true
- peerDependencies:
- typescript: ^5.x
-
"@humanfs/core@0.19.1":
resolution:
{
@@ -573,10 +802,10 @@ packages:
integrity: sha512-p+Zh1sb6EfrfVaS86jlHGQ9HA66fJhV9x5LiE5vCbZtXEHAuhcmUZUdZ4WrFpUBfNalr2OkAJI5AcKEQF+Lebw==,
}
- "@ioredis/commands@1.3.0":
+ "@ioredis/commands@1.3.1":
resolution:
{
- integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==,
+ integrity: sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==,
}
"@isaacs/cliui@8.0.2":
@@ -586,6 +815,107 @@ packages:
}
engines: { node: ">=12" }
+ "@istanbuljs/load-nyc-config@1.1.0":
+ resolution:
+ {
+ integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==,
+ }
+ engines: { node: ">=8" }
+
+ "@istanbuljs/schema@0.1.3":
+ resolution:
+ {
+ integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==,
+ }
+ engines: { node: ">=8" }
+
+ "@jest/console@30.1.2":
+ resolution:
+ {
+ integrity: sha512-BGMAxj8VRmoD0MoA/jo9alMXSRoqW8KPeqOfEo1ncxnRLatTBCpRoOwlwlEMdudp68Q6WSGwYrrLtTGOh8fLzw==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/core@30.1.3":
+ resolution:
+ {
+ integrity: sha512-LIQz7NEDDO1+eyOA2ZmkiAyYvZuo6s1UxD/e2IHldR6D7UYogVq3arTmli07MkENLq6/3JEQjp0mA8rrHHJ8KQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+ peerDependencies:
+ node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
+ peerDependenciesMeta:
+ node-notifier:
+ optional: true
+
+ "@jest/diff-sequences@30.0.1":
+ resolution:
+ {
+ integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/environment@30.1.2":
+ resolution:
+ {
+ integrity: sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/expect-utils@30.1.2":
+ resolution:
+ {
+ integrity: sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/expect@30.1.2":
+ resolution:
+ {
+ integrity: sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/fake-timers@30.1.2":
+ resolution:
+ {
+ integrity: sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/get-type@30.1.0":
+ resolution:
+ {
+ integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/globals@30.1.2":
+ resolution:
+ {
+ integrity: sha512-teNTPZ8yZe3ahbYnvnVRDeOjr+3pu2uiAtNtrEsiMjVPPj+cXd5E/fr8BL7v/T7F31vYdEHrI5cC/2OoO/vM9A==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/pattern@30.0.1":
+ resolution:
+ {
+ integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/reporters@30.1.3":
+ resolution:
+ {
+ integrity: sha512-VWEQmJWfXMOrzdFEOyGjUEOuVXllgZsoPtEHZzfdNz18RmzJ5nlR6kp8hDdY8dDS1yGOXAY7DHT+AOHIPSBV0w==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+ peerDependencies:
+ node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
+ peerDependenciesMeta:
+ node-notifier:
+ optional: true
+
"@jest/schemas@29.6.3":
resolution:
{
@@ -593,6 +923,48 @@ packages:
}
engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 }
+ "@jest/schemas@30.0.5":
+ resolution:
+ {
+ integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/snapshot-utils@30.1.2":
+ resolution:
+ {
+ integrity: sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/source-map@30.0.1":
+ resolution:
+ {
+ integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/test-result@30.1.3":
+ resolution:
+ {
+ integrity: sha512-P9IV8T24D43cNRANPPokn7tZh0FAFnYS2HIfi5vK18CjRkTDR9Y3e1BoEcAJnl4ghZZF4Ecda4M/k41QkvurEQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/test-sequencer@30.1.3":
+ resolution:
+ {
+ integrity: sha512-82J+hzC0qeQIiiZDThh+YUadvshdBswi5nuyXlEmXzrhw5ZQSRHeQ5LpVMD/xc8B3wPePvs6VMzHnntxL+4E3w==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ "@jest/transform@30.1.2":
+ resolution:
+ {
+ integrity: sha512-UYYFGifSgfjujf1Cbd3iU/IQoSd6uwsj8XHj5DSDf5ERDcWMdJOPTkHWXj4U+Z/uMagyOQZ6Vne8C4nRIrCxqA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
"@jest/types@29.6.3":
resolution:
{
@@ -600,6 +972,13 @@ packages:
}
engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 }
+ "@jest/types@30.0.5":
+ resolution:
+ {
+ integrity: sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
"@jridgewell/gen-mapping@0.3.13":
resolution:
{
@@ -631,12 +1010,6 @@ packages:
integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==,
}
- "@jsdevtools/ono@7.1.3":
- resolution:
- {
- integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==,
- }
-
"@mapbox/node-pre-gyp@1.0.11":
resolution:
{
@@ -914,6 +1287,13 @@ packages:
}
engines: { node: ">=14" }
+ "@pkgr/core@0.2.9":
+ resolution:
+ {
+ integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==,
+ }
+ engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 }
+
"@radix-ui/primitive@1.1.3":
resolution:
{
@@ -1198,6 +1578,25 @@ packages:
integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==,
}
+ "@redocly/ajv@8.11.3":
+ resolution:
+ {
+ integrity: sha512-4P3iZse91TkBiY+Dx5DUgxQ9GXkVJf++cmI0MOyLDxV9b5MUBI4II6ES8zA5JCbO72nKAJxWrw4PUPW+YP3ZDQ==,
+ }
+
+ "@redocly/config@0.22.2":
+ resolution:
+ {
+ integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==,
+ }
+
+ "@redocly/openapi-core@1.34.5":
+ resolution:
+ {
+ integrity: sha512-0EbE8LRbkogtcCXU7liAyC00n9uNG9hJ+eMyHFdUsy9lB/WGqnEBgwjA9q2cyzAVcdTkQqTBBU1XePNnN3OijA==,
+ }
+ engines: { node: ">=18.17.0", npm: ">=9.5.0" }
+
"@reduxjs/toolkit@2.8.2":
resolution:
{
@@ -1382,6 +1781,24 @@ packages:
integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==,
}
+ "@sinclair/typebox@0.34.41":
+ resolution:
+ {
+ integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==,
+ }
+
+ "@sinonjs/commons@3.0.1":
+ resolution:
+ {
+ integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==,
+ }
+
+ "@sinonjs/fake-timers@13.0.5":
+ resolution:
+ {
+ integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==,
+ }
+
"@socket.io/component-emitter@3.1.2":
resolution:
{
@@ -1418,6 +1835,20 @@ packages:
integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==,
}
+ "@tanstack/query-core@5.85.9":
+ resolution:
+ {
+ integrity: sha512-5fxb9vwyftYE6KFLhhhDyLr8NO75+Wpu7pmTo+TkwKmMX2oxZDoLwcqGP8ItKSpUMwk3urWgQDZfyWr5Jm9LsQ==,
+ }
+
+ "@tanstack/react-query@5.85.9":
+ resolution:
+ {
+ integrity: sha512-2T5zgSpcOZXGkH/UObIbIkGmUPQqZqn7esVQFXLOze622h4spgWf5jmvrqAo9dnI13/hyMcNsF1jsoDcb59nJQ==,
+ }
+ peerDependencies:
+ react: ^18 || ^19
+
"@tootallnate/once@2.0.0":
resolution:
{
@@ -1461,6 +1892,30 @@ packages:
integrity: sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==,
}
+ "@types/babel__core@7.20.5":
+ resolution:
+ {
+ integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==,
+ }
+
+ "@types/babel__generator@7.27.0":
+ resolution:
+ {
+ integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==,
+ }
+
+ "@types/babel__template@7.4.4":
+ resolution:
+ {
+ integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==,
+ }
+
+ "@types/babel__traverse@7.28.0":
+ resolution:
+ {
+ integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==,
+ }
+
"@types/debug@4.1.12":
resolution:
{
@@ -1491,6 +1946,13 @@ packages:
integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==,
}
+ "@types/ioredis@5.0.0":
+ resolution:
+ {
+ integrity: sha512-zJbJ3FVE17CNl5KXzdeSPtdltc4tMT3TzC6fxQS0sQngkbFZ6h+0uTafsRqu+eSLIugf6Yb0Ea0SUuRr42Nk9g==,
+ }
+ deprecated: This is a stub types definition. ioredis provides its own type definitions, so you do not need this installed.
+
"@types/istanbul-lib-coverage@2.0.6":
resolution:
{
@@ -1509,6 +1971,12 @@ packages:
integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==,
}
+ "@types/jest@30.0.0":
+ resolution:
+ {
+ integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==,
+ }
+
"@types/json-schema@7.0.15":
resolution:
{
@@ -1575,6 +2043,12 @@ packages:
integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==,
}
+ "@types/stack-utils@2.0.3":
+ resolution:
+ {
+ integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==,
+ }
+
"@types/ua-parser-js@0.7.39":
resolution:
{
@@ -1888,12 +2362,6 @@ packages:
cpu: [x64]
os: [win32]
- "@upstash/redis@1.35.3":
- resolution:
- {
- integrity: sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w==,
- }
-
"@vercel/build-utils@8.4.12":
resolution:
{
@@ -1950,13 +2418,6 @@ packages:
integrity: sha512-IPAVaALuGAzt2apvTtBs5tB+8zZRzn/yG3AGp8dFyCsw/v5YOuk0Q5s8Z3fayLvJbFpjrKtqRNDZzVJBBU3MrQ==,
}
- "@vercel/kv@2.0.0":
- resolution:
- {
- integrity: sha512-zdVrhbzZBYo5d1Hfn4bKtqCeKf0FuzW8rSHauzQVMUgv1+1JOwof2mWcBuI+YMJy8s0G0oqAUfQ7HgUDzb8EbA==,
- }
- engines: { node: ">=14.6" }
-
"@vercel/next@4.3.18":
resolution:
{
@@ -2502,6 +2963,13 @@ packages:
}
engines: { node: ">= 6.0.0" }
+ agent-base@7.1.4:
+ resolution:
+ {
+ integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==,
+ }
+ engines: { node: ">= 14" }
+
ajv@6.12.6:
resolution:
{
@@ -2514,6 +2982,20 @@ packages:
integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==,
}
+ ansi-colors@4.1.3:
+ resolution:
+ {
+ integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==,
+ }
+ engines: { node: ">=6" }
+
+ ansi-escapes@4.3.2:
+ resolution:
+ {
+ integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==,
+ }
+ engines: { node: ">=8" }
+
ansi-regex@5.0.1:
resolution:
{
@@ -2535,6 +3017,13 @@ packages:
}
engines: { node: ">=8" }
+ ansi-styles@5.2.0:
+ resolution:
+ {
+ integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==,
+ }
+ engines: { node: ">=10" }
+
ansi-styles@6.2.1:
resolution:
{
@@ -2587,6 +3076,12 @@ packages:
integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==,
}
+ argparse@1.0.10:
+ resolution:
+ {
+ integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==,
+ }
+
argparse@2.0.1:
resolution:
{
@@ -2758,6 +3253,29 @@ packages:
}
engines: { node: ">= 0.4" }
+ babel-jest@30.1.2:
+ resolution:
+ {
+ integrity: sha512-IQCus1rt9kaSh7PQxLYRY5NmkNrNlU2TpabzwV7T2jljnpdHOcmnYYv8QmE04Li4S3a2Lj8/yXyET5pBarPr6g==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+ peerDependencies:
+ "@babel/core": ^7.11.0
+
+ babel-plugin-istanbul@7.0.0:
+ resolution:
+ {
+ integrity: sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==,
+ }
+ engines: { node: ">=12" }
+
+ babel-plugin-jest-hoist@30.0.1:
+ resolution:
+ {
+ integrity: sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
babel-plugin-macros@3.1.0:
resolution:
{
@@ -2765,6 +3283,23 @@ packages:
}
engines: { node: ">=10", npm: ">=6" }
+ babel-preset-current-node-syntax@1.2.0:
+ resolution:
+ {
+ integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==,
+ }
+ peerDependencies:
+ "@babel/core": ^7.0.0 || ^8.0.0-0
+
+ babel-preset-jest@30.0.1:
+ resolution:
+ {
+ integrity: sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+ peerDependencies:
+ "@babel/core": ^7.11.0
+
bail@2.0.2:
resolution:
{
@@ -2823,6 +3358,19 @@ packages:
engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 }
hasBin: true
+ bs-logger@0.2.6:
+ resolution:
+ {
+ integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==,
+ }
+ engines: { node: ">= 6" }
+
+ bser@2.1.1:
+ resolution:
+ {
+ integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==,
+ }
+
btoa@1.2.1:
resolution:
{
@@ -2837,6 +3385,12 @@ packages:
integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==,
}
+ buffer-from@1.1.2:
+ resolution:
+ {
+ integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==,
+ }
+
buffer@6.0.3:
resolution:
{
@@ -2857,17 +3411,6 @@ packages:
}
engines: { node: ">= 0.8" }
- c12@1.11.1:
- resolution:
- {
- integrity: sha512-KDU0TvSvVdaYcQKQ6iPHATGz/7p/KiVjPg4vQrB6Jg/wX9R0yl5RZxWm9IoZqaIHD2+6PZd81+KMGwRr/lRIUg==,
- }
- peerDependencies:
- magicast: ^0.3.4
- peerDependenciesMeta:
- magicast:
- optional: true
-
call-bind-apply-helpers@1.0.2:
resolution:
{
@@ -2903,12 +3446,19 @@ packages:
}
engines: { node: ">= 6" }
- camelcase@8.0.0:
+ camelcase@5.3.1:
resolution:
{
- integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==,
+ integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==,
}
- engines: { node: ">=16" }
+ engines: { node: ">=6" }
+
+ camelcase@6.3.0:
+ resolution:
+ {
+ integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==,
+ }
+ engines: { node: ">=10" }
caniuse-lite@1.0.30001734:
resolution:
@@ -2936,6 +3486,19 @@ packages:
}
engines: { node: ">=10" }
+ change-case@5.4.4:
+ resolution:
+ {
+ integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==,
+ }
+
+ char-regex@1.0.2:
+ resolution:
+ {
+ integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==,
+ }
+ engines: { node: ">=10" }
+
character-entities-html4@2.1.0:
resolution:
{
@@ -3007,11 +3570,12 @@ packages:
}
engines: { node: ">=8" }
- citty@0.1.6:
+ ci-info@4.3.0:
resolution:
{
- integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==,
+ integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==,
}
+ engines: { node: ">=8" }
cjs-module-lexer@1.2.3:
resolution:
@@ -3019,6 +3583,12 @@ packages:
integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==,
}
+ cjs-module-lexer@2.1.0:
+ resolution:
+ {
+ integrity: sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==,
+ }
+
classnames@2.5.1:
resolution:
{
@@ -3031,6 +3601,13 @@ packages:
integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==,
}
+ cliui@8.0.1:
+ resolution:
+ {
+ integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==,
+ }
+ engines: { node: ">=12" }
+
clsx@2.1.1:
resolution:
{
@@ -3045,12 +3622,25 @@ packages:
}
engines: { node: ">=0.10.0" }
+ co@4.6.0:
+ resolution:
+ {
+ integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==,
+ }
+ engines: { iojs: ">= 1.0.0", node: ">= 0.12.0" }
+
code-block-writer@10.1.1:
resolution:
{
integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==,
}
+ collect-v8-coverage@1.0.2:
+ resolution:
+ {
+ integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==,
+ }
+
color-convert@2.0.1:
resolution:
{
@@ -3071,6 +3661,12 @@ packages:
}
hasBin: true
+ colorette@1.4.0:
+ resolution:
+ {
+ integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==,
+ }
+
combined-stream@1.0.8:
resolution:
{
@@ -3084,13 +3680,6 @@ packages:
integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==,
}
- commander@12.1.0:
- resolution:
- {
- integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==,
- }
- engines: { node: ">=18" }
-
commander@4.1.1:
resolution:
{
@@ -3110,19 +3699,6 @@ packages:
integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==,
}
- confbox@0.1.8:
- resolution:
- {
- integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==,
- }
-
- consola@3.4.2:
- resolution:
- {
- integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==,
- }
- engines: { node: ^14.18.0 || >=16.10.0 }
-
console-control-strings@1.1.0:
resolution:
{
@@ -3149,6 +3725,12 @@ packages:
integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==,
}
+ convert-source-map@2.0.0:
+ resolution:
+ {
+ integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==,
+ }
+
cookie@0.7.2:
resolution:
{
@@ -3270,12 +3852,30 @@ packages:
integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==,
}
+ dedent@1.7.0:
+ resolution:
+ {
+ integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==,
+ }
+ peerDependencies:
+ babel-plugin-macros: ^3.1.0
+ peerDependenciesMeta:
+ babel-plugin-macros:
+ optional: true
+
deep-is@0.1.4:
resolution:
{
integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==,
}
+ deepmerge@4.3.1:
+ resolution:
+ {
+ integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==,
+ }
+ engines: { node: ">=0.10.0" }
+
define-data-property@1.1.4:
resolution:
{
@@ -3290,12 +3890,6 @@ packages:
}
engines: { node: ">= 0.4" }
- defu@6.1.4:
- resolution:
- {
- integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==,
- }
-
delayed-stream@1.0.0:
resolution:
{
@@ -3330,12 +3924,6 @@ packages:
}
engines: { node: ">=6" }
- destr@2.0.5:
- resolution:
- {
- integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==,
- }
-
detect-europe-js@0.1.2:
resolution:
{
@@ -3357,6 +3945,13 @@ packages:
}
engines: { node: ">=8" }
+ detect-newline@3.1.0:
+ resolution:
+ {
+ integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==,
+ }
+ engines: { node: ">=8" }
+
detect-node-es@1.1.0:
resolution:
{
@@ -3413,13 +4008,6 @@ packages:
integrity: sha512-h7g5eduvnLwowJJPkcB5lNzo8vd/Hx4e3I4IOtLpX0qB2wBiuryGLNa61MeFre4b6gMaQIhegMIZ2I8rQCAJwQ==,
}
- dotenv@16.6.1:
- resolution:
- {
- integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==,
- }
- engines: { node: ">=12" }
-
dunder-proto@1.0.1:
resolution:
{
@@ -3447,6 +4035,13 @@ packages:
integrity: sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==,
}
+ emittery@0.13.1:
+ resolution:
+ {
+ integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==,
+ }
+ engines: { node: ">=12" }
+
emoji-regex@8.0.0:
resolution:
{
@@ -3753,6 +4348,13 @@ packages:
}
engines: { node: ">=6" }
+ escape-string-regexp@2.0.0:
+ resolution:
+ {
+ integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==,
+ }
+ engines: { node: ">=8" }
+
escape-string-regexp@4.0.0:
resolution:
{
@@ -3899,6 +4501,14 @@ packages:
}
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
+ esprima@4.0.1:
+ resolution:
+ {
+ integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==,
+ }
+ engines: { node: ">=4" }
+ hasBin: true
+
esquery@1.6.0:
resolution:
{
@@ -3973,6 +4583,27 @@ packages:
}
engines: { node: ^8.12.0 || >=9.7.0 }
+ execa@5.1.1:
+ resolution:
+ {
+ integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==,
+ }
+ engines: { node: ">=10" }
+
+ exit-x@0.2.2:
+ resolution:
+ {
+ integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==,
+ }
+ engines: { node: ">= 0.8.0" }
+
+ expect@30.1.2:
+ resolution:
+ {
+ integrity: sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
extend@3.0.2:
resolution:
{
@@ -4022,6 +4653,12 @@ packages:
integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==,
}
+ fb-watchman@2.0.2:
+ resolution:
+ {
+ integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==,
+ }
+
fd-slicer@1.1.0:
resolution:
{
@@ -4065,6 +4702,13 @@ packages:
integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==,
}
+ find-up@4.1.0:
+ resolution:
+ {
+ integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==,
+ }
+ engines: { node: ">=8" }
+
find-up@5.0.0:
resolution:
{
@@ -4213,12 +4857,26 @@ packages:
}
engines: { node: ">= 4" }
+ gensync@1.0.0-beta.2:
+ resolution:
+ {
+ integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==,
+ }
+ engines: { node: ">=6.9.0" }
+
get-browser-rtc@1.1.0:
resolution:
{
integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==,
}
+ get-caller-file@2.0.5:
+ resolution:
+ {
+ integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==,
+ }
+ engines: { node: 6.* || 8.* || >= 10.* }
+
get-intrinsic@1.3.0:
resolution:
{
@@ -4233,6 +4891,13 @@ packages:
}
engines: { node: ">=6" }
+ get-package-type@0.1.0:
+ resolution:
+ {
+ integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==,
+ }
+ engines: { node: ">=8.0.0" }
+
get-proto@1.0.1:
resolution:
{
@@ -4247,6 +4912,13 @@ packages:
}
engines: { node: ">=8" }
+ get-stream@6.0.1:
+ resolution:
+ {
+ integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==,
+ }
+ engines: { node: ">=10" }
+
get-symbol-description@1.1.0:
resolution:
{
@@ -4260,13 +4932,6 @@ packages:
integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==,
}
- giget@1.2.5:
- resolution:
- {
- integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==,
- }
- hasBin: true
-
glob-parent@5.1.2:
resolution:
{
@@ -4437,6 +5102,12 @@ packages:
integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==,
}
+ html-escaper@2.0.2:
+ resolution:
+ {
+ integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==,
+ }
+
html-url-attributes@3.0.1:
resolution:
{
@@ -4464,6 +5135,13 @@ packages:
}
engines: { node: ">= 6" }
+ https-proxy-agent@7.0.6:
+ resolution:
+ {
+ integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==,
+ }
+ engines: { node: ">= 14" }
+
human-signals@1.1.1:
resolution:
{
@@ -4471,6 +5149,13 @@ packages:
}
engines: { node: ">=8.12.0" }
+ human-signals@2.1.0:
+ resolution:
+ {
+ integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==,
+ }
+ engines: { node: ">=10.17.0" }
+
hyperhtml-style@0.1.3:
resolution:
{
@@ -4529,6 +5214,14 @@ packages:
}
engines: { node: ">=6" }
+ import-local@3.2.0:
+ resolution:
+ {
+ integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==,
+ }
+ engines: { node: ">=8" }
+ hasBin: true
+
imurmurhash@0.1.4:
resolution:
{
@@ -4536,6 +5229,13 @@ packages:
}
engines: { node: ">=0.8.19" }
+ index-to-position@1.1.0:
+ resolution:
+ {
+ integrity: sha512-XPdx9Dq4t9Qk1mTMbWONJqU7boCoumEH7fRET37HX5+khDUl3J2W6PdALxhILYlIYx2amlwYcRPp28p0tSiojg==,
+ }
+ engines: { node: ">=18" }
+
inflight@1.0.6:
resolution:
{
@@ -4709,6 +5409,13 @@ packages:
}
engines: { node: ">=8" }
+ is-generator-fn@2.1.0:
+ resolution:
+ {
+ integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==,
+ }
+ engines: { node: ">=6" }
+
is-generator-function@1.1.0:
resolution:
{
@@ -4864,6 +5571,41 @@ packages:
integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==,
}
+ istanbul-lib-coverage@3.2.2:
+ resolution:
+ {
+ integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==,
+ }
+ engines: { node: ">=8" }
+
+ istanbul-lib-instrument@6.0.3:
+ resolution:
+ {
+ integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==,
+ }
+ engines: { node: ">=10" }
+
+ istanbul-lib-report@3.0.1:
+ resolution:
+ {
+ integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==,
+ }
+ engines: { node: ">=10" }
+
+ istanbul-lib-source-maps@5.0.6:
+ resolution:
+ {
+ integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==,
+ }
+ engines: { node: ">=10" }
+
+ istanbul-reports@3.2.0:
+ resolution:
+ {
+ integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==,
+ }
+ engines: { node: ">=8" }
+
iterator.prototype@1.1.5:
resolution:
{
@@ -4884,6 +5626,168 @@ packages:
integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==,
}
+ jest-changed-files@30.0.5:
+ resolution:
+ {
+ integrity: sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-circus@30.1.3:
+ resolution:
+ {
+ integrity: sha512-Yf3dnhRON2GJT4RYzM89t/EXIWNxKTpWTL9BfF3+geFetWP4XSvJjiU1vrWplOiUkmq8cHLiwuhz+XuUp9DscA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-cli@30.1.3:
+ resolution:
+ {
+ integrity: sha512-G8E2Ol3OKch1DEeIBl41NP7OiC6LBhfg25Btv+idcusmoUSpqUkbrneMqbW9lVpI/rCKb/uETidb7DNteheuAQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+ hasBin: true
+ peerDependencies:
+ node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
+ peerDependenciesMeta:
+ node-notifier:
+ optional: true
+
+ jest-config@30.1.3:
+ resolution:
+ {
+ integrity: sha512-M/f7gqdQEPgZNA181Myz+GXCe8jXcJsGjCMXUzRj22FIXsZOyHNte84e0exntOvdPaeh9tA0w+B8qlP2fAezfw==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+ peerDependencies:
+ "@types/node": "*"
+ esbuild-register: ">=3.4.0"
+ ts-node: ">=9.0.0"
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ esbuild-register:
+ optional: true
+ ts-node:
+ optional: true
+
+ jest-diff@30.1.2:
+ resolution:
+ {
+ integrity: sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-docblock@30.0.1:
+ resolution:
+ {
+ integrity: sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-each@30.1.0:
+ resolution:
+ {
+ integrity: sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-environment-node@30.1.2:
+ resolution:
+ {
+ integrity: sha512-w8qBiXtqGWJ9xpJIA98M0EIoq079GOQRQUyse5qg1plShUCQ0Ek1VTTcczqKrn3f24TFAgFtT+4q3aOXvjbsuA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-haste-map@30.1.0:
+ resolution:
+ {
+ integrity: sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-leak-detector@30.1.0:
+ resolution:
+ {
+ integrity: sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-matcher-utils@30.1.2:
+ resolution:
+ {
+ integrity: sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-message-util@30.1.0:
+ resolution:
+ {
+ integrity: sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-mock@30.0.5:
+ resolution:
+ {
+ integrity: sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-pnp-resolver@1.2.3:
+ resolution:
+ {
+ integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==,
+ }
+ engines: { node: ">=6" }
+ peerDependencies:
+ jest-resolve: "*"
+ peerDependenciesMeta:
+ jest-resolve:
+ optional: true
+
+ jest-regex-util@30.0.1:
+ resolution:
+ {
+ integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-resolve-dependencies@30.1.3:
+ resolution:
+ {
+ integrity: sha512-DNfq3WGmuRyHRHfEet+Zm3QOmVFtIarUOQHHryKPc0YL9ROfgWZxl4+aZq/VAzok2SS3gZdniP+dO4zgo59hBg==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-resolve@30.1.3:
+ resolution:
+ {
+ integrity: sha512-DI4PtTqzw9GwELFS41sdMK32Ajp3XZQ8iygeDMWkxlRhm7uUTOFSZFVZABFuxr0jvspn8MAYy54NxZCsuCTSOw==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-runner@30.1.3:
+ resolution:
+ {
+ integrity: sha512-dd1ORcxQraW44Uz029TtXj85W11yvLpDuIzNOlofrC8GN+SgDlgY4BvyxJiVeuabA1t6idjNbX59jLd2oplOGQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-runtime@30.1.3:
+ resolution:
+ {
+ integrity: sha512-WS8xgjuNSphdIGnleQcJ3AKE4tBKOVP+tKhCD0u+Tb2sBmsU8DxfbBpZX7//+XOz81zVs4eFpJQwBNji2Y07DA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-snapshot@30.1.2:
+ resolution:
+ {
+ integrity: sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
jest-util@29.7.0:
resolution:
{
@@ -4891,6 +5795,27 @@ packages:
}
engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 }
+ jest-util@30.0.5:
+ resolution:
+ {
+ integrity: sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-validate@30.1.0:
+ resolution:
+ {
+ integrity: sha512-7P3ZlCFW/vhfQ8pE7zW6Oi4EzvuB4sgR72Q1INfW9m0FGo0GADYlPwIkf4CyPq7wq85g+kPMtPOHNAdWHeBOaA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest-watcher@30.1.3:
+ resolution:
+ {
+ integrity: sha512-6jQUZCP1BTL2gvG9E4YF06Ytq4yMb4If6YoQGRR6PpjtqOXSP3sKe2kqwB6SQ+H9DezOfZaSLnmka1NtGm3fCQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
jest-worker@29.7.0:
resolution:
{
@@ -4898,6 +5823,26 @@ packages:
}
engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 }
+ jest-worker@30.1.0:
+ resolution:
+ {
+ integrity: sha512-uvWcSjlwAAgIu133Tt77A05H7RIk3Ho8tZL50bQM2AkvLdluw9NG48lRCl3Dt+MOH719n/0nnb5YxUwcuJiKRA==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
+ jest@30.1.3:
+ resolution:
+ {
+ integrity: sha512-Ry+p2+NLk6u8Agh5yVqELfUJvRfV51hhVBRIB5yZPY7mU0DGBmOuFG5GebZbMbm86cdQNK0fhJuDX8/1YorISQ==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+ hasBin: true
+ peerDependencies:
+ node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0
+ peerDependenciesMeta:
+ node-notifier:
+ optional: true
+
jiti@1.21.7:
resolution:
{
@@ -4911,12 +5856,26 @@ packages:
integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==,
}
+ js-levenshtein@1.1.6:
+ resolution:
+ {
+ integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==,
+ }
+ engines: { node: ">=0.10.0" }
+
js-tokens@4.0.0:
resolution:
{
integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==,
}
+ js-yaml@3.14.1:
+ resolution:
+ {
+ integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==,
+ }
+ hasBin: true
+
js-yaml@4.1.0:
resolution:
{
@@ -4981,6 +5940,14 @@ packages:
}
hasBin: true
+ json5@2.2.3:
+ resolution:
+ {
+ integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==,
+ }
+ engines: { node: ">=6" }
+ hasBin: true
+
jsonfile@4.0.0:
resolution:
{
@@ -5019,6 +5986,13 @@ packages:
}
engines: { node: ">=0.10" }
+ leven@3.1.0:
+ resolution:
+ {
+ integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==,
+ }
+ engines: { node: ">=6" }
+
levn@0.4.1:
resolution:
{
@@ -5057,6 +6031,13 @@ packages:
integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==,
}
+ locate-path@5.0.0:
+ resolution:
+ {
+ integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==,
+ }
+ engines: { node: ">=8" }
+
locate-path@6.0.0:
resolution:
{
@@ -5076,6 +6057,12 @@ packages:
integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==,
}
+ lodash.memoize@4.1.2:
+ resolution:
+ {
+ integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==,
+ }
+
lodash.merge@4.6.2:
resolution:
{
@@ -5101,6 +6088,12 @@ packages:
integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==,
}
+ lru-cache@5.1.1:
+ resolution:
+ {
+ integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==,
+ }
+
lru-cache@6.0.0:
resolution:
{
@@ -5130,12 +6123,25 @@ packages:
}
engines: { node: ">=8" }
+ make-dir@4.0.0:
+ resolution:
+ {
+ integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==,
+ }
+ engines: { node: ">=10" }
+
make-error@1.3.6:
resolution:
{
integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==,
}
+ makeerror@1.0.12:
+ resolution:
+ {
+ integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==,
+ }
+
math-intrinsics@1.1.0:
resolution:
{
@@ -5460,12 +6466,6 @@ packages:
engines: { node: ">=10" }
hasBin: true
- mlly@1.7.4:
- resolution:
- {
- integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==,
- }
-
mri@1.2.0:
resolution:
{
@@ -5566,24 +6566,12 @@ packages:
sass:
optional: true
- node-abort-controller@3.1.1:
- resolution:
- {
- integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==,
- }
-
node-addon-api@7.1.1:
resolution:
{
integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==,
}
- node-fetch-native@1.6.7:
- resolution:
- {
- integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==,
- }
-
node-fetch@2.6.7:
resolution:
{
@@ -5627,6 +6615,12 @@ packages:
}
hasBin: true
+ node-int64@0.4.0:
+ resolution:
+ {
+ integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==,
+ }
+
node-releases@2.0.19:
resolution:
{
@@ -5690,14 +6684,6 @@ packages:
react-router-dom:
optional: true
- nypm@0.5.4:
- resolution:
- {
- integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==,
- }
- engines: { node: ^14.16.0 || >=16.10.0 }
- hasBin: true
-
oauth@0.9.15:
resolution:
{
@@ -5774,12 +6760,6 @@ packages:
}
engines: { node: ">= 0.4" }
- ohash@1.1.6:
- resolution:
- {
- integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==,
- }
-
oidc-token-hash@5.1.1:
resolution:
{
@@ -5806,6 +6786,36 @@ packages:
}
engines: { node: ">=6" }
+ openapi-fetch@0.14.0:
+ resolution:
+ {
+ integrity: sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==,
+ }
+
+ openapi-react-query@0.5.0:
+ resolution:
+ {
+ integrity: sha512-VtyqiamsbWsdSWtXmj/fAR+m9nNxztsof6h8ZIsjRj8c8UR/x9AIwHwd60IqwgymmFwo7qfSJQ1ZzMJrtqjQVg==,
+ }
+ peerDependencies:
+ "@tanstack/react-query": ^5.25.0
+ openapi-fetch: ^0.14.0
+
+ openapi-typescript-helpers@0.0.15:
+ resolution:
+ {
+ integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==,
+ }
+
+ openapi-typescript@7.9.1:
+ resolution:
+ {
+ integrity: sha512-9gJtoY04mk6iPMbToPjPxEAtfXZ0dTsMZtsgUI8YZta0btPPig9DJFP4jlerQD/7QOwYgb0tl+zLUpDf7vb7VA==,
+ }
+ hasBin: true
+ peerDependencies:
+ typescript: ^5.x
+
openid-client@5.7.1:
resolution:
{
@@ -5840,6 +6850,13 @@ packages:
}
engines: { node: ">=8" }
+ p-limit@2.3.0:
+ resolution:
+ {
+ integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==,
+ }
+ engines: { node: ">=6" }
+
p-limit@3.1.0:
resolution:
{
@@ -5847,6 +6864,13 @@ packages:
}
engines: { node: ">=10" }
+ p-locate@4.1.0:
+ resolution:
+ {
+ integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==,
+ }
+ engines: { node: ">=8" }
+
p-locate@5.0.0:
resolution:
{
@@ -5854,6 +6878,13 @@ packages:
}
engines: { node: ">=10" }
+ p-try@2.2.0:
+ resolution:
+ {
+ integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==,
+ }
+ engines: { node: ">=6" }
+
package-json-from-dist@1.0.1:
resolution:
{
@@ -5880,6 +6911,13 @@ packages:
}
engines: { node: ">=8" }
+ parse-json@8.3.0:
+ resolution:
+ {
+ integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==,
+ }
+ engines: { node: ">=18" }
+
parse-ms@2.1.0:
resolution:
{
@@ -5959,30 +6997,12 @@ packages:
}
engines: { node: ">=8" }
- pathe@1.1.2:
- resolution:
- {
- integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==,
- }
-
- pathe@2.0.3:
- resolution:
- {
- integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==,
- }
-
pend@1.2.0:
resolution:
{
integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==,
}
- perfect-debounce@1.0.0:
- resolution:
- {
- integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==,
- }
-
perfect-freehand@1.2.2:
resolution:
{
@@ -6029,11 +7049,19 @@ packages:
}
engines: { node: ">= 6" }
- pkg-types@1.3.1:
+ pkg-dir@4.2.0:
resolution:
{
- integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==,
+ integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==,
}
+ engines: { node: ">=8" }
+
+ pluralize@8.0.0:
+ resolution:
+ {
+ integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==,
+ }
+ engines: { node: ">=4" }
possible-typed-array-names@1.1.0:
resolution:
@@ -6146,6 +7174,13 @@ packages:
integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==,
}
+ pretty-format@30.0.5:
+ resolution:
+ {
+ integrity: sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==,
+ }
+ engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+
pretty-ms@7.0.1:
resolution:
{
@@ -6209,6 +7244,12 @@ packages:
}
engines: { node: ">=6" }
+ pure-rand@7.0.1:
+ resolution:
+ {
+ integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==,
+ }
+
qr.js@0.0.0:
resolution:
{
@@ -6234,12 +7275,6 @@ packages:
}
engines: { node: ">= 0.8" }
- rc9@2.1.2:
- resolution:
- {
- integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==,
- }
-
react-dom@18.3.1:
resolution:
{
@@ -6271,6 +7306,12 @@ packages:
integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==,
}
+ react-is@18.3.1:
+ resolution:
+ {
+ integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==,
+ }
+
react-markdown@9.1.0:
resolution:
{
@@ -6392,13 +7433,6 @@ packages:
}
engines: { node: ">=4" }
- redlock@5.0.0-beta.2:
- resolution:
- {
- integrity: sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw==,
- }
- engines: { node: ">=12" }
-
redux-thunk@3.1.0:
resolution:
{
@@ -6439,6 +7473,13 @@ packages:
integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==,
}
+ require-directory@2.1.1:
+ resolution:
+ {
+ integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==,
+ }
+ engines: { node: ">=0.10.0" }
+
require-from-string@2.0.2:
resolution:
{
@@ -6458,6 +7499,13 @@ packages:
integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==,
}
+ resolve-cwd@3.0.0:
+ resolution:
+ {
+ integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==,
+ }
+ engines: { node: ">=8" }
+
resolve-from@4.0.0:
resolution:
{
@@ -6727,6 +7775,13 @@ packages:
integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==,
}
+ slash@3.0.0:
+ resolution:
+ {
+ integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==,
+ }
+ engines: { node: ">=8" }
+
socket.io-client@4.7.2:
resolution:
{
@@ -6748,6 +7803,12 @@ packages:
}
engines: { node: ">=0.10.0" }
+ source-map-support@0.5.13:
+ resolution:
+ {
+ integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==,
+ }
+
source-map@0.5.7:
resolution:
{
@@ -6768,6 +7829,12 @@ packages:
integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==,
}
+ sprintf-js@1.0.3:
+ resolution:
+ {
+ integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==,
+ }
+
sprintf-js@1.1.3:
resolution:
{
@@ -6780,6 +7847,13 @@ packages:
integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==,
}
+ stack-utils@2.0.6:
+ resolution:
+ {
+ integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==,
+ }
+ engines: { node: ">=10" }
+
stacktrace-parser@0.1.11:
resolution:
{
@@ -6832,6 +7906,13 @@ packages:
}
engines: { node: ">=10.0.0" }
+ string-length@4.0.2:
+ resolution:
+ {
+ integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==,
+ }
+ engines: { node: ">=10" }
+
string-width@4.2.3:
resolution:
{
@@ -6920,6 +8001,13 @@ packages:
}
engines: { node: ">=4" }
+ strip-bom@4.0.0:
+ resolution:
+ {
+ integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==,
+ }
+ engines: { node: ">=8" }
+
strip-final-newline@2.0.0:
resolution:
{
@@ -6976,6 +8064,13 @@ packages:
engines: { node: ">=16 || 14 >=14.17" }
hasBin: true
+ supports-color@10.2.0:
+ resolution:
+ {
+ integrity: sha512-5eG9FQjEjDbAlI5+kdpdyPIBMRH4GfTVDGREVupaZHmVoppknhM29b/S9BkQz7cathp85BVgRi/As3Siln7e0Q==,
+ }
+ engines: { node: ">=18" }
+
supports-color@7.2.0:
resolution:
{
@@ -7004,6 +8099,13 @@ packages:
}
engines: { node: ">= 0.4" }
+ synckit@0.11.11:
+ resolution:
+ {
+ integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==,
+ }
+ engines: { node: ^14.18.0 || >=16.0.0 }
+
tailwindcss@3.4.17:
resolution:
{
@@ -7026,6 +8128,13 @@ packages:
}
engines: { node: ">=10" }
+ test-exclude@6.0.0:
+ resolution:
+ {
+ integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==,
+ }
+ engines: { node: ">=8" }
+
thenify-all@1.6.0:
resolution:
{
@@ -7046,12 +8155,6 @@ packages:
}
engines: { node: ">=10" }
- tinyexec@0.3.2:
- resolution:
- {
- integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==,
- }
-
tinyglobby@0.2.14:
resolution:
{
@@ -7059,6 +8162,12 @@ packages:
}
engines: { node: ">=12.0.0" }
+ tmpl@1.0.5:
+ resolution:
+ {
+ integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==,
+ }
+
to-regex-range@5.0.1:
resolution:
{
@@ -7113,6 +8222,36 @@ packages:
integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==,
}
+ ts-jest@29.4.1:
+ resolution:
+ {
+ integrity: sha512-SaeUtjfpg9Uqu8IbeDKtdaS0g8lS6FT6OzM3ezrDfErPJPHNDo/Ey+VFGP1bQIDfagYDLyRpd7O15XpG1Es2Uw==,
+ }
+ engines: { node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0 }
+ hasBin: true
+ peerDependencies:
+ "@babel/core": ">=7.0.0-beta.0 <8"
+ "@jest/transform": ^29.0.0 || ^30.0.0
+ "@jest/types": ^29.0.0 || ^30.0.0
+ babel-jest: ^29.0.0 || ^30.0.0
+ esbuild: "*"
+ jest: ^29.0.0 || ^30.0.0
+ jest-util: ^29.0.0 || ^30.0.0
+ typescript: ">=4.3 <6"
+ peerDependenciesMeta:
+ "@babel/core":
+ optional: true
+ "@jest/transform":
+ optional: true
+ "@jest/types":
+ optional: true
+ babel-jest:
+ optional: true
+ esbuild:
+ optional: true
+ jest-util:
+ optional: true
+
ts-morph@12.0.0:
resolution:
{
@@ -7161,6 +8300,20 @@ packages:
}
engines: { node: ">= 0.8.0" }
+ type-detect@4.0.8:
+ resolution:
+ {
+ integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==,
+ }
+ engines: { node: ">=4" }
+
+ type-fest@0.21.3:
+ resolution:
+ {
+ integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==,
+ }
+ engines: { node: ">=10" }
+
type-fest@0.7.1:
resolution:
{
@@ -7168,6 +8321,13 @@ packages:
}
engines: { node: ">=8" }
+ type-fest@4.41.0:
+ resolution:
+ {
+ integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==,
+ }
+ engines: { node: ">=16" }
+
typed-array-buffer@1.0.3:
resolution:
{
@@ -7244,12 +8404,6 @@ packages:
integrity: sha512-v+Z8Jal+GtmKGtJ34GIQlCJAxrDt9kbjpNsNvYoAXFyr4gNfWlD4uJJuoNNu/0UTVaKvQwHaSU095YDl71lKPw==,
}
- ufo@1.6.1:
- resolution:
- {
- integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==,
- }
-
uglify-js@3.19.3:
resolution:
{
@@ -7289,12 +8443,6 @@ packages:
}
engines: { node: ">= 0.4" }
- uncrypto@0.1.3:
- resolution:
- {
- integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==,
- }
-
undici-types@7.10.0:
resolution:
{
@@ -7386,6 +8534,12 @@ packages:
integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==,
}
+ uri-js-replace@1.0.1:
+ resolution:
+ {
+ integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==,
+ }
+
uri-js@4.4.1:
resolution:
{
@@ -7464,6 +8618,13 @@ packages:
integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==,
}
+ v8-to-istanbul@9.3.0:
+ resolution:
+ {
+ integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==,
+ }
+ engines: { node: ">=10.12.0" }
+
vercel@37.14.0:
resolution:
{
@@ -7484,6 +8645,12 @@ packages:
integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==,
}
+ walker@1.0.8:
+ resolution:
+ {
+ integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==,
+ }
+
wavesurfer.js@7.10.1:
resolution:
{
@@ -7597,6 +8764,13 @@ packages:
integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==,
}
+ write-file-atomic@5.0.1:
+ resolution:
+ {
+ integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==,
+ }
+ engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 }
+
ws@8.17.1:
resolution:
{
@@ -7633,6 +8807,13 @@ packages:
}
engines: { node: ">=0.4.0" }
+ y18n@5.0.8:
+ resolution:
+ {
+ integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==,
+ }
+ engines: { node: ">=10" }
+
yallist@3.1.1:
resolution:
{
@@ -7645,6 +8826,12 @@ packages:
integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==,
}
+ yaml-ast-parser@0.0.43:
+ resolution:
+ {
+ integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==,
+ }
+
yaml@1.10.2:
resolution:
{
@@ -7660,6 +8847,20 @@ packages:
engines: { node: ">= 14.6" }
hasBin: true
+ yargs-parser@21.1.1:
+ resolution:
+ {
+ integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==,
+ }
+ engines: { node: ">=12" }
+
+ yargs@17.7.2:
+ resolution:
+ {
+ integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==,
+ }
+ engines: { node: ">=12" }
+
yauzl-clone@1.0.4:
resolution:
{
@@ -7694,6 +8895,12 @@ packages:
}
engines: { node: ">=10" }
+ zod@4.1.5:
+ resolution:
+ {
+ integrity: sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==,
+ }
+
zwitch@2.0.4:
resolution:
{
@@ -7703,11 +8910,10 @@ packages:
snapshots:
"@alloc/quick-lru@5.2.0": {}
- "@apidevtools/json-schema-ref-parser@11.6.4":
+ "@ampproject/remapping@2.3.0":
dependencies:
- "@jsdevtools/ono": 7.1.3
- "@types/json-schema": 7.0.15
- js-yaml: 4.1.0
+ "@jridgewell/gen-mapping": 0.3.13
+ "@jridgewell/trace-mapping": 0.3.30
"@ark-ui/react@5.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)":
dependencies:
@@ -7779,6 +8985,28 @@ snapshots:
js-tokens: 4.0.0
picocolors: 1.1.1
+ "@babel/compat-data@7.28.0": {}
+
+ "@babel/core@7.28.3":
+ dependencies:
+ "@ampproject/remapping": 2.3.0
+ "@babel/code-frame": 7.27.1
+ "@babel/generator": 7.28.3
+ "@babel/helper-compilation-targets": 7.27.2
+ "@babel/helper-module-transforms": 7.28.3(@babel/core@7.28.3)
+ "@babel/helpers": 7.28.3
+ "@babel/parser": 7.28.3
+ "@babel/template": 7.27.2
+ "@babel/traverse": 7.28.3
+ "@babel/types": 7.28.2
+ convert-source-map: 2.0.0
+ debug: 4.4.1(supports-color@9.4.0)
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
"@babel/generator@7.28.0":
dependencies:
"@babel/parser": 7.28.0
@@ -7787,6 +9015,22 @@ snapshots:
"@jridgewell/trace-mapping": 0.3.30
jsesc: 3.1.0
+ "@babel/generator@7.28.3":
+ dependencies:
+ "@babel/parser": 7.28.3
+ "@babel/types": 7.28.2
+ "@jridgewell/gen-mapping": 0.3.13
+ "@jridgewell/trace-mapping": 0.3.30
+ jsesc: 3.1.0
+
+ "@babel/helper-compilation-targets@7.27.2":
+ dependencies:
+ "@babel/compat-data": 7.28.0
+ "@babel/helper-validator-option": 7.27.1
+ browserslist: 4.25.2
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
"@babel/helper-globals@7.28.0": {}
"@babel/helper-module-imports@7.27.1":
@@ -7796,14 +9040,121 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ "@babel/helper-module-transforms@7.28.3(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-module-imports": 7.27.1
+ "@babel/helper-validator-identifier": 7.27.1
+ "@babel/traverse": 7.28.3
+ transitivePeerDependencies:
+ - supports-color
+
+ "@babel/helper-plugin-utils@7.27.1": {}
+
"@babel/helper-string-parser@7.27.1": {}
"@babel/helper-validator-identifier@7.27.1": {}
+ "@babel/helper-validator-option@7.27.1": {}
+
+ "@babel/helpers@7.28.3":
+ dependencies:
+ "@babel/template": 7.27.2
+ "@babel/types": 7.28.2
+
"@babel/parser@7.28.0":
dependencies:
"@babel/types": 7.28.2
+ "@babel/parser@7.28.3":
+ dependencies:
+ "@babel/types": 7.28.2
+
+ "@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
+ "@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.3)":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/helper-plugin-utils": 7.27.1
+
"@babel/runtime@7.28.2": {}
"@babel/template@7.27.2":
@@ -7824,11 +9175,25 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ "@babel/traverse@7.28.3":
+ dependencies:
+ "@babel/code-frame": 7.27.1
+ "@babel/generator": 7.28.3
+ "@babel/helper-globals": 7.28.0
+ "@babel/parser": 7.28.3
+ "@babel/template": 7.27.2
+ "@babel/types": 7.28.2
+ debug: 4.4.1(supports-color@9.4.0)
+ transitivePeerDependencies:
+ - supports-color
+
"@babel/types@7.28.2":
dependencies:
"@babel/helper-string-parser": 7.27.1
"@babel/helper-validator-identifier": 7.27.1
+ "@bcoe/v8-coverage@0.2.3": {}
+
"@chakra-ui/react@3.24.2(@emotion/react@11.14.0(@types/react@18.2.20)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)":
dependencies:
"@ark-ui/react": 5.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -8027,17 +9392,6 @@ snapshots:
prop-types: 15.8.1
react: 18.3.1
- "@hey-api/openapi-ts@0.48.3(typescript@5.9.2)":
- dependencies:
- "@apidevtools/json-schema-ref-parser": 11.6.4
- c12: 1.11.1
- camelcase: 8.0.0
- commander: 12.1.0
- handlebars: 4.7.8
- typescript: 5.9.2
- transitivePeerDependencies:
- - magicast
-
"@humanfs/core@0.19.1": {}
"@humanfs/node@0.16.6":
@@ -8059,7 +9413,7 @@ snapshots:
dependencies:
"@swc/helpers": 0.5.17
- "@ioredis/commands@1.3.0": {}
+ "@ioredis/commands@1.3.1": {}
"@isaacs/cliui@8.0.2":
dependencies:
@@ -8070,10 +9424,189 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
+ "@istanbuljs/load-nyc-config@1.1.0":
+ dependencies:
+ camelcase: 5.3.1
+ find-up: 4.1.0
+ get-package-type: 0.1.0
+ js-yaml: 3.14.1
+ resolve-from: 5.0.0
+
+ "@istanbuljs/schema@0.1.3": {}
+
+ "@jest/console@30.1.2":
+ dependencies:
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ chalk: 4.1.2
+ jest-message-util: 30.1.0
+ jest-util: 30.0.5
+ slash: 3.0.0
+
+ "@jest/core@30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))":
+ dependencies:
+ "@jest/console": 30.1.2
+ "@jest/pattern": 30.0.1
+ "@jest/reporters": 30.1.3
+ "@jest/test-result": 30.1.3
+ "@jest/transform": 30.1.2
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ ansi-escapes: 4.3.2
+ chalk: 4.1.2
+ ci-info: 4.3.0
+ exit-x: 0.2.2
+ graceful-fs: 4.2.11
+ jest-changed-files: 30.0.5
+ jest-config: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ jest-haste-map: 30.1.0
+ jest-message-util: 30.1.0
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.1.3
+ jest-resolve-dependencies: 30.1.3
+ jest-runner: 30.1.3
+ jest-runtime: 30.1.3
+ jest-snapshot: 30.1.2
+ jest-util: 30.0.5
+ jest-validate: 30.1.0
+ jest-watcher: 30.1.3
+ micromatch: 4.0.8
+ pretty-format: 30.0.5
+ slash: 3.0.0
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+
+ "@jest/diff-sequences@30.0.1": {}
+
+ "@jest/environment@30.1.2":
+ dependencies:
+ "@jest/fake-timers": 30.1.2
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ jest-mock: 30.0.5
+
+ "@jest/expect-utils@30.1.2":
+ dependencies:
+ "@jest/get-type": 30.1.0
+
+ "@jest/expect@30.1.2":
+ dependencies:
+ expect: 30.1.2
+ jest-snapshot: 30.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ "@jest/fake-timers@30.1.2":
+ dependencies:
+ "@jest/types": 30.0.5
+ "@sinonjs/fake-timers": 13.0.5
+ "@types/node": 24.2.1
+ jest-message-util: 30.1.0
+ jest-mock: 30.0.5
+ jest-util: 30.0.5
+
+ "@jest/get-type@30.1.0": {}
+
+ "@jest/globals@30.1.2":
+ dependencies:
+ "@jest/environment": 30.1.2
+ "@jest/expect": 30.1.2
+ "@jest/types": 30.0.5
+ jest-mock: 30.0.5
+ transitivePeerDependencies:
+ - supports-color
+
+ "@jest/pattern@30.0.1":
+ dependencies:
+ "@types/node": 24.2.1
+ jest-regex-util: 30.0.1
+
+ "@jest/reporters@30.1.3":
+ dependencies:
+ "@bcoe/v8-coverage": 0.2.3
+ "@jest/console": 30.1.2
+ "@jest/test-result": 30.1.3
+ "@jest/transform": 30.1.2
+ "@jest/types": 30.0.5
+ "@jridgewell/trace-mapping": 0.3.30
+ "@types/node": 24.2.1
+ chalk: 4.1.2
+ collect-v8-coverage: 1.0.2
+ exit-x: 0.2.2
+ glob: 10.4.5
+ graceful-fs: 4.2.11
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-instrument: 6.0.3
+ istanbul-lib-report: 3.0.1
+ istanbul-lib-source-maps: 5.0.6
+ istanbul-reports: 3.2.0
+ jest-message-util: 30.1.0
+ jest-util: 30.0.5
+ jest-worker: 30.1.0
+ slash: 3.0.0
+ string-length: 4.0.2
+ v8-to-istanbul: 9.3.0
+ transitivePeerDependencies:
+ - supports-color
+
"@jest/schemas@29.6.3":
dependencies:
"@sinclair/typebox": 0.27.8
+ "@jest/schemas@30.0.5":
+ dependencies:
+ "@sinclair/typebox": 0.34.41
+
+ "@jest/snapshot-utils@30.1.2":
+ dependencies:
+ "@jest/types": 30.0.5
+ chalk: 4.1.2
+ graceful-fs: 4.2.11
+ natural-compare: 1.4.0
+
+ "@jest/source-map@30.0.1":
+ dependencies:
+ "@jridgewell/trace-mapping": 0.3.30
+ callsites: 3.1.0
+ graceful-fs: 4.2.11
+
+ "@jest/test-result@30.1.3":
+ dependencies:
+ "@jest/console": 30.1.2
+ "@jest/types": 30.0.5
+ "@types/istanbul-lib-coverage": 2.0.6
+ collect-v8-coverage: 1.0.2
+
+ "@jest/test-sequencer@30.1.3":
+ dependencies:
+ "@jest/test-result": 30.1.3
+ graceful-fs: 4.2.11
+ jest-haste-map: 30.1.0
+ slash: 3.0.0
+
+ "@jest/transform@30.1.2":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@jest/types": 30.0.5
+ "@jridgewell/trace-mapping": 0.3.30
+ babel-plugin-istanbul: 7.0.0
+ chalk: 4.1.2
+ convert-source-map: 2.0.0
+ fast-json-stable-stringify: 2.1.0
+ graceful-fs: 4.2.11
+ jest-haste-map: 30.1.0
+ jest-regex-util: 30.0.1
+ jest-util: 30.0.5
+ micromatch: 4.0.8
+ pirates: 4.0.7
+ slash: 3.0.0
+ write-file-atomic: 5.0.1
+ transitivePeerDependencies:
+ - supports-color
+
"@jest/types@29.6.3":
dependencies:
"@jest/schemas": 29.6.3
@@ -8083,6 +9616,16 @@ snapshots:
"@types/yargs": 17.0.33
chalk: 4.1.2
+ "@jest/types@30.0.5":
+ dependencies:
+ "@jest/pattern": 30.0.1
+ "@jest/schemas": 30.0.5
+ "@types/istanbul-lib-coverage": 2.0.6
+ "@types/istanbul-reports": 3.0.4
+ "@types/node": 24.2.1
+ "@types/yargs": 17.0.33
+ chalk: 4.1.2
+
"@jridgewell/gen-mapping@0.3.13":
dependencies:
"@jridgewell/sourcemap-codec": 1.5.5
@@ -8102,8 +9645,6 @@ snapshots:
"@jridgewell/resolve-uri": 3.1.2
"@jridgewell/sourcemap-codec": 1.5.5
- "@jsdevtools/ono@7.1.3": {}
-
"@mapbox/node-pre-gyp@1.0.11":
dependencies:
detect-libc: 2.0.4
@@ -8241,6 +9782,8 @@ snapshots:
"@pkgjs/parseargs@0.11.0":
optional: true
+ "@pkgr/core@0.2.9": {}
+
"@radix-ui/primitive@1.1.3": {}
"@radix-ui/react-arrow@1.1.7(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)":
@@ -8420,6 +9963,29 @@ snapshots:
"@radix-ui/rect@1.1.1": {}
+ "@redocly/ajv@8.11.3":
+ dependencies:
+ fast-deep-equal: 3.1.3
+ json-schema-traverse: 1.0.0
+ require-from-string: 2.0.2
+ uri-js-replace: 1.0.1
+
+ "@redocly/config@0.22.2": {}
+
+ "@redocly/openapi-core@1.34.5(supports-color@10.2.0)":
+ dependencies:
+ "@redocly/ajv": 8.11.3
+ "@redocly/config": 0.22.2
+ colorette: 1.4.0
+ https-proxy-agent: 7.0.6(supports-color@10.2.0)
+ js-levenshtein: 1.1.6
+ js-yaml: 4.1.0
+ minimatch: 5.1.6
+ pluralize: 8.0.0
+ yaml-ast-parser: 0.0.43
+ transitivePeerDependencies:
+ - supports-color
+
"@reduxjs/toolkit@2.8.2(react@18.3.1)":
dependencies:
"@standard-schema/spec": 1.0.0
@@ -8513,7 +10079,7 @@ snapshots:
"@sentry/utils": 7.120.4
localforage: 1.10.0
- "@sentry/nextjs@7.120.4(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)":
+ "@sentry/nextjs@7.120.4(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)":
dependencies:
"@rollup/plugin-commonjs": 24.0.0(rollup@2.79.2)
"@sentry/core": 7.120.4
@@ -8525,7 +10091,7 @@ snapshots:
"@sentry/vercel-edge": 7.120.4
"@sentry/webpack-plugin": 1.21.0
chalk: 3.0.0
- next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
react: 18.3.1
resolve: 1.22.8
rollup: 2.79.2
@@ -8584,6 +10150,16 @@ snapshots:
"@sinclair/typebox@0.27.8": {}
+ "@sinclair/typebox@0.34.41": {}
+
+ "@sinonjs/commons@3.0.1":
+ dependencies:
+ type-detect: 4.0.8
+
+ "@sinonjs/fake-timers@13.0.5":
+ dependencies:
+ "@sinonjs/commons": 3.0.1
+
"@socket.io/component-emitter@3.1.2": {}
"@standard-schema/spec@1.0.0": {}
@@ -8601,6 +10177,13 @@ snapshots:
"@swc/counter": 0.1.3
tslib: 2.8.1
+ "@tanstack/query-core@5.85.9": {}
+
+ "@tanstack/react-query@5.85.9(react@18.3.1)":
+ dependencies:
+ "@tanstack/query-core": 5.85.9
+ react: 18.3.1
+
"@tootallnate/once@2.0.0": {}
"@ts-morph/common@0.11.1":
@@ -8623,6 +10206,27 @@ snapshots:
tslib: 2.8.1
optional: true
+ "@types/babel__core@7.20.5":
+ dependencies:
+ "@babel/parser": 7.28.0
+ "@babel/types": 7.28.2
+ "@types/babel__generator": 7.27.0
+ "@types/babel__template": 7.4.4
+ "@types/babel__traverse": 7.28.0
+
+ "@types/babel__generator@7.27.0":
+ dependencies:
+ "@babel/types": 7.28.2
+
+ "@types/babel__template@7.4.4":
+ dependencies:
+ "@babel/parser": 7.28.0
+ "@babel/types": 7.28.2
+
+ "@types/babel__traverse@7.28.0":
+ dependencies:
+ "@babel/types": 7.28.2
+
"@types/debug@4.1.12":
dependencies:
"@types/ms": 2.1.0
@@ -8639,6 +10243,12 @@ snapshots:
dependencies:
"@types/unist": 3.0.3
+ "@types/ioredis@5.0.0":
+ dependencies:
+ ioredis: 5.7.0
+ transitivePeerDependencies:
+ - supports-color
+
"@types/istanbul-lib-coverage@2.0.6": {}
"@types/istanbul-lib-report@3.0.3":
@@ -8649,6 +10259,11 @@ snapshots:
dependencies:
"@types/istanbul-lib-report": 3.0.3
+ "@types/jest@30.0.0":
+ dependencies:
+ expect: 30.1.2
+ pretty-format: 30.0.5
+
"@types/json-schema@7.0.15": {}
"@types/json5@0.0.29": {}
@@ -8682,6 +10297,8 @@ snapshots:
"@types/scheduler@0.26.0": {}
+ "@types/stack-utils@2.0.3": {}
+
"@types/ua-parser-js@0.7.39": {}
"@types/unist@2.0.11": {}
@@ -8860,10 +10477,6 @@ snapshots:
"@unrs/resolver-binding-win32-x64-msvc@1.11.1":
optional: true
- "@upstash/redis@1.35.3":
- dependencies:
- uncrypto: 0.1.3
-
"@vercel/build-utils@8.4.12": {}
"@vercel/edge-config-fs@0.1.0": {}
@@ -8920,10 +10533,6 @@ snapshots:
"@vercel/static-config": 3.0.0
ts-morph: 12.0.0
- "@vercel/kv@2.0.0":
- dependencies:
- "@upstash/redis": 1.35.3
-
"@vercel/next@4.3.18":
dependencies:
"@vercel/nft": 0.27.3
@@ -9601,6 +11210,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ agent-base@7.1.4: {}
+
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -9615,6 +11226,12 @@ snapshots:
require-from-string: 2.0.2
uri-js: 4.4.1
+ ansi-colors@4.1.3: {}
+
+ ansi-escapes@4.3.2:
+ dependencies:
+ type-fest: 0.21.3
+
ansi-regex@5.0.1: {}
ansi-regex@6.1.0: {}
@@ -9623,6 +11240,8 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ ansi-styles@5.2.0: {}
+
ansi-styles@6.2.1: {}
any-promise@1.3.0: {}
@@ -9645,6 +11264,10 @@ snapshots:
arg@5.0.2: {}
+ argparse@1.0.10:
+ dependencies:
+ sprintf-js: 1.0.3
+
argparse@2.0.1: {}
aria-hidden@1.2.6:
@@ -9771,12 +11394,66 @@ snapshots:
axobject-query@4.1.0: {}
+ babel-jest@30.1.2(@babel/core@7.28.3):
+ dependencies:
+ "@babel/core": 7.28.3
+ "@jest/transform": 30.1.2
+ "@types/babel__core": 7.20.5
+ babel-plugin-istanbul: 7.0.0
+ babel-preset-jest: 30.0.1(@babel/core@7.28.3)
+ chalk: 4.1.2
+ graceful-fs: 4.2.11
+ slash: 3.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ babel-plugin-istanbul@7.0.0:
+ dependencies:
+ "@babel/helper-plugin-utils": 7.27.1
+ "@istanbuljs/load-nyc-config": 1.1.0
+ "@istanbuljs/schema": 0.1.3
+ istanbul-lib-instrument: 6.0.3
+ test-exclude: 6.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ babel-plugin-jest-hoist@30.0.1:
+ dependencies:
+ "@babel/template": 7.27.2
+ "@babel/types": 7.28.2
+ "@types/babel__core": 7.20.5
+
babel-plugin-macros@3.1.0:
dependencies:
"@babel/runtime": 7.28.2
cosmiconfig: 7.1.0
resolve: 1.22.10
+ babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.3):
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/plugin-syntax-async-generators": 7.8.4(@babel/core@7.28.3)
+ "@babel/plugin-syntax-bigint": 7.8.3(@babel/core@7.28.3)
+ "@babel/plugin-syntax-class-properties": 7.12.13(@babel/core@7.28.3)
+ "@babel/plugin-syntax-class-static-block": 7.14.5(@babel/core@7.28.3)
+ "@babel/plugin-syntax-import-attributes": 7.27.1(@babel/core@7.28.3)
+ "@babel/plugin-syntax-import-meta": 7.10.4(@babel/core@7.28.3)
+ "@babel/plugin-syntax-json-strings": 7.8.3(@babel/core@7.28.3)
+ "@babel/plugin-syntax-logical-assignment-operators": 7.10.4(@babel/core@7.28.3)
+ "@babel/plugin-syntax-nullish-coalescing-operator": 7.8.3(@babel/core@7.28.3)
+ "@babel/plugin-syntax-numeric-separator": 7.10.4(@babel/core@7.28.3)
+ "@babel/plugin-syntax-object-rest-spread": 7.8.3(@babel/core@7.28.3)
+ "@babel/plugin-syntax-optional-catch-binding": 7.8.3(@babel/core@7.28.3)
+ "@babel/plugin-syntax-optional-chaining": 7.8.3(@babel/core@7.28.3)
+ "@babel/plugin-syntax-private-property-in-object": 7.14.5(@babel/core@7.28.3)
+ "@babel/plugin-syntax-top-level-await": 7.14.5(@babel/core@7.28.3)
+
+ babel-preset-jest@30.0.1(@babel/core@7.28.3):
+ dependencies:
+ "@babel/core": 7.28.3
+ babel-plugin-jest-hoist: 30.0.1
+ babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.3)
+
bail@2.0.2: {}
balanced-match@1.0.2: {}
@@ -9809,10 +11486,20 @@ snapshots:
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.2)
+ bs-logger@0.2.6:
+ dependencies:
+ fast-json-stable-stringify: 2.1.0
+
+ bser@2.1.1:
+ dependencies:
+ node-int64: 0.4.0
+
btoa@1.2.1: {}
buffer-crc32@0.2.13: {}
+ buffer-from@1.1.2: {}
+
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@@ -9824,21 +11511,6 @@ snapshots:
bytes@3.1.0: {}
- c12@1.11.1:
- dependencies:
- chokidar: 3.6.0
- confbox: 0.1.8
- defu: 6.1.4
- dotenv: 16.6.1
- giget: 1.2.5
- jiti: 1.21.7
- mlly: 1.7.4
- ohash: 1.1.6
- pathe: 1.1.2
- perfect-debounce: 1.0.0
- pkg-types: 1.3.1
- rc9: 2.1.2
-
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -9860,7 +11532,9 @@ snapshots:
camelcase-css@2.0.1: {}
- camelcase@8.0.0: {}
+ camelcase@5.3.1: {}
+
+ camelcase@6.3.0: {}
caniuse-lite@1.0.30001734: {}
@@ -9876,6 +11550,10 @@ snapshots:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ change-case@5.4.4: {}
+
+ char-regex@1.0.2: {}
+
character-entities-html4@2.1.0: {}
character-entities-legacy@3.0.0: {}
@@ -9922,22 +11600,32 @@ snapshots:
ci-info@3.9.0: {}
- citty@0.1.6:
- dependencies:
- consola: 3.4.2
+ ci-info@4.3.0: {}
cjs-module-lexer@1.2.3: {}
+ cjs-module-lexer@2.1.0: {}
+
classnames@2.5.1: {}
client-only@0.0.1: {}
+ cliui@8.0.1:
+ dependencies:
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ wrap-ansi: 7.0.0
+
clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
+ co@4.6.0: {}
+
code-block-writer@10.1.1: {}
+ collect-v8-coverage@1.0.2: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -9946,24 +11634,20 @@ snapshots:
color-support@1.1.3: {}
+ colorette@1.4.0: {}
+
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@2.0.3: {}
- commander@12.1.0: {}
-
commander@4.1.1: {}
commondir@1.0.1: {}
concat-map@0.0.1: {}
- confbox@0.1.8: {}
-
- consola@3.4.2: {}
-
console-control-strings@1.1.0: {}
content-type@1.0.4: {}
@@ -9972,6 +11656,8 @@ snapshots:
convert-source-map@1.9.0: {}
+ convert-source-map@2.0.0: {}
+
cookie@0.7.2: {}
cosmiconfig@7.1.0:
@@ -10026,6 +11712,12 @@ snapshots:
dependencies:
ms: 2.1.3
+ debug@4.4.1(supports-color@10.2.0):
+ dependencies:
+ ms: 2.1.3
+ optionalDependencies:
+ supports-color: 10.2.0
+
debug@4.4.1(supports-color@9.4.0):
dependencies:
ms: 2.1.3
@@ -10036,8 +11728,14 @@ snapshots:
dependencies:
character-entities: 2.0.2
+ dedent@1.7.0(babel-plugin-macros@3.1.0):
+ optionalDependencies:
+ babel-plugin-macros: 3.1.0
+
deep-is@0.1.4: {}
+ deepmerge@4.3.1: {}
+
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@@ -10050,8 +11748,6 @@ snapshots:
has-property-descriptors: 1.0.2
object-keys: 1.1.1
- defu@6.1.4: {}
-
delayed-stream@1.0.0: {}
delegates@1.0.0: {}
@@ -10062,8 +11758,6 @@ snapshots:
dequal@2.0.3: {}
- destr@2.0.5: {}
-
detect-europe-js@0.1.2: {}
detect-libc@1.0.3:
@@ -10071,6 +11765,8 @@ snapshots:
detect-libc@2.0.4: {}
+ detect-newline@3.1.0: {}
+
detect-node-es@1.1.0: {}
devlop@1.1.0:
@@ -10103,8 +11799,6 @@ snapshots:
domsanitizer: 0.2.3
umap: 1.0.2
- dotenv@16.6.1: {}
-
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -10127,6 +11821,8 @@ snapshots:
electron-to-chromium@1.5.200: {}
+ emittery@0.13.1: {}
+
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@@ -10347,6 +12043,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-string-regexp@2.0.0: {}
+
escape-string-regexp@4.0.0: {}
eslint-config-next@14.2.31(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2):
@@ -10534,6 +12232,8 @@ snapshots:
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1
+ esprima@4.0.1: {}
+
esquery@1.6.0:
dependencies:
estraverse: 5.3.0
@@ -10571,6 +12271,29 @@ snapshots:
signal-exit: 3.0.7
strip-final-newline: 2.0.0
+ execa@5.1.1:
+ dependencies:
+ cross-spawn: 7.0.6
+ get-stream: 6.0.1
+ human-signals: 2.1.0
+ is-stream: 2.0.1
+ merge-stream: 2.0.0
+ npm-run-path: 4.0.1
+ onetime: 5.1.2
+ signal-exit: 3.0.7
+ strip-final-newline: 2.0.0
+
+ exit-x@0.2.2: {}
+
+ expect@30.1.2:
+ dependencies:
+ "@jest/expect-utils": 30.1.2
+ "@jest/get-type": 30.1.0
+ jest-matcher-utils: 30.1.2
+ jest-message-util: 30.1.0
+ jest-mock: 30.0.5
+ jest-util: 30.0.5
+
extend@3.0.2: {}
fake-mediastreamtrack@1.2.0:
@@ -10598,6 +12321,10 @@ snapshots:
dependencies:
reusify: 1.1.0
+ fb-watchman@2.0.2:
+ dependencies:
+ bser: 2.1.1
+
fd-slicer@1.1.0:
dependencies:
pend: 1.2.0
@@ -10618,6 +12345,11 @@ snapshots:
find-root@1.1.0: {}
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -10708,8 +12440,12 @@ snapshots:
generic-pool@3.4.2: {}
+ gensync@1.0.0-beta.2: {}
+
get-browser-rtc@1.1.0: {}
+ get-caller-file@2.0.5: {}
+
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -10725,6 +12461,8 @@ snapshots:
get-nonce@1.0.1: {}
+ get-package-type@0.1.0: {}
+
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
@@ -10734,6 +12472,8 @@ snapshots:
dependencies:
pump: 3.0.3
+ get-stream@6.0.1: {}
+
get-symbol-description@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -10744,16 +12484,6 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
- giget@1.2.5:
- dependencies:
- citty: 0.1.6
- consola: 3.4.2
- defu: 6.1.4
- node-fetch-native: 1.6.7
- nypm: 0.5.4
- pathe: 2.0.3
- tar: 6.2.1
-
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -10885,6 +12615,8 @@ snapshots:
dependencies:
react-is: 16.13.1
+ html-escaper@2.0.2: {}
+
html-url-attributes@3.0.1: {}
http-errors@1.4.0:
@@ -10907,8 +12639,17 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ https-proxy-agent@7.0.6(supports-color@10.2.0):
+ dependencies:
+ agent-base: 7.1.4
+ debug: 4.4.1(supports-color@10.2.0)
+ transitivePeerDependencies:
+ - supports-color
+
human-signals@1.1.1: {}
+ human-signals@2.1.0: {}
+
hyperhtml-style@0.1.3: {}
iconv-lite@0.4.24:
@@ -10932,8 +12673,15 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
+ import-local@3.2.0:
+ dependencies:
+ pkg-dir: 4.2.0
+ resolve-cwd: 3.0.0
+
imurmurhash@0.1.4: {}
+ index-to-position@1.1.0: {}
+
inflight@1.0.6:
dependencies:
once: 1.4.0
@@ -10953,7 +12701,7 @@ snapshots:
ioredis@5.7.0:
dependencies:
- "@ioredis/commands": 1.3.0
+ "@ioredis/commands": 1.3.1
cluster-key-slot: 1.1.2
debug: 4.4.1(supports-color@9.4.0)
denque: 2.1.0
@@ -11043,6 +12791,8 @@ snapshots:
is-fullwidth-code-point@3.0.0: {}
+ is-generator-fn@2.1.0: {}
+
is-generator-function@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -11122,6 +12872,37 @@ snapshots:
isexe@2.0.0: {}
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-instrument@6.0.3:
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/parser": 7.28.0
+ "@istanbuljs/schema": 0.1.3
+ istanbul-lib-coverage: 3.2.2
+ semver: 7.7.2
+ transitivePeerDependencies:
+ - supports-color
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-lib-source-maps@5.0.6:
+ dependencies:
+ "@jridgewell/trace-mapping": 0.3.30
+ debug: 4.4.1(supports-color@9.4.0)
+ istanbul-lib-coverage: 3.2.2
+ transitivePeerDependencies:
+ - supports-color
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
iterator.prototype@1.1.5:
dependencies:
define-data-property: 1.1.4
@@ -11143,6 +12924,301 @@ snapshots:
optionalDependencies:
"@pkgjs/parseargs": 0.11.0
+ jest-changed-files@30.0.5:
+ dependencies:
+ execa: 5.1.1
+ jest-util: 30.0.5
+ p-limit: 3.1.0
+
+ jest-circus@30.1.3(babel-plugin-macros@3.1.0):
+ dependencies:
+ "@jest/environment": 30.1.2
+ "@jest/expect": 30.1.2
+ "@jest/test-result": 30.1.3
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ chalk: 4.1.2
+ co: 4.6.0
+ dedent: 1.7.0(babel-plugin-macros@3.1.0)
+ is-generator-fn: 2.1.0
+ jest-each: 30.1.0
+ jest-matcher-utils: 30.1.2
+ jest-message-util: 30.1.0
+ jest-runtime: 30.1.3
+ jest-snapshot: 30.1.2
+ jest-util: 30.0.5
+ p-limit: 3.1.0
+ pretty-format: 30.0.5
+ pure-rand: 7.0.1
+ slash: 3.0.0
+ stack-utils: 2.0.6
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+
+ jest-cli@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ dependencies:
+ "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ "@jest/test-result": 30.1.3
+ "@jest/types": 30.0.5
+ chalk: 4.1.2
+ exit-x: 0.2.2
+ import-local: 3.2.0
+ jest-config: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ jest-util: 30.0.5
+ jest-validate: 30.1.0
+ yargs: 17.7.2
+ transitivePeerDependencies:
+ - "@types/node"
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+
+ jest-config@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ dependencies:
+ "@babel/core": 7.28.3
+ "@jest/get-type": 30.1.0
+ "@jest/pattern": 30.0.1
+ "@jest/test-sequencer": 30.1.3
+ "@jest/types": 30.0.5
+ babel-jest: 30.1.2(@babel/core@7.28.3)
+ chalk: 4.1.2
+ ci-info: 4.3.0
+ deepmerge: 4.3.1
+ glob: 10.4.5
+ graceful-fs: 4.2.11
+ jest-circus: 30.1.3(babel-plugin-macros@3.1.0)
+ jest-docblock: 30.0.1
+ jest-environment-node: 30.1.2
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.1.3
+ jest-runner: 30.1.3
+ jest-util: 30.0.5
+ jest-validate: 30.1.0
+ micromatch: 4.0.8
+ parse-json: 5.2.0
+ pretty-format: 30.0.5
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ "@types/node": 16.18.11
+ ts-node: 10.9.1(@types/node@16.18.11)(typescript@5.9.2)
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+
+ jest-config@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ dependencies:
+ "@babel/core": 7.28.3
+ "@jest/get-type": 30.1.0
+ "@jest/pattern": 30.0.1
+ "@jest/test-sequencer": 30.1.3
+ "@jest/types": 30.0.5
+ babel-jest: 30.1.2(@babel/core@7.28.3)
+ chalk: 4.1.2
+ ci-info: 4.3.0
+ deepmerge: 4.3.1
+ glob: 10.4.5
+ graceful-fs: 4.2.11
+ jest-circus: 30.1.3(babel-plugin-macros@3.1.0)
+ jest-docblock: 30.0.1
+ jest-environment-node: 30.1.2
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.1.3
+ jest-runner: 30.1.3
+ jest-util: 30.0.5
+ jest-validate: 30.1.0
+ micromatch: 4.0.8
+ parse-json: 5.2.0
+ pretty-format: 30.0.5
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ "@types/node": 24.2.1
+ ts-node: 10.9.1(@types/node@16.18.11)(typescript@5.9.2)
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+
+ jest-diff@30.1.2:
+ dependencies:
+ "@jest/diff-sequences": 30.0.1
+ "@jest/get-type": 30.1.0
+ chalk: 4.1.2
+ pretty-format: 30.0.5
+
+ jest-docblock@30.0.1:
+ dependencies:
+ detect-newline: 3.1.0
+
+ jest-each@30.1.0:
+ dependencies:
+ "@jest/get-type": 30.1.0
+ "@jest/types": 30.0.5
+ chalk: 4.1.2
+ jest-util: 30.0.5
+ pretty-format: 30.0.5
+
+ jest-environment-node@30.1.2:
+ dependencies:
+ "@jest/environment": 30.1.2
+ "@jest/fake-timers": 30.1.2
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ jest-mock: 30.0.5
+ jest-util: 30.0.5
+ jest-validate: 30.1.0
+
+ jest-haste-map@30.1.0:
+ dependencies:
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ anymatch: 3.1.3
+ fb-watchman: 2.0.2
+ graceful-fs: 4.2.11
+ jest-regex-util: 30.0.1
+ jest-util: 30.0.5
+ jest-worker: 30.1.0
+ micromatch: 4.0.8
+ walker: 1.0.8
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ jest-leak-detector@30.1.0:
+ dependencies:
+ "@jest/get-type": 30.1.0
+ pretty-format: 30.0.5
+
+ jest-matcher-utils@30.1.2:
+ dependencies:
+ "@jest/get-type": 30.1.0
+ chalk: 4.1.2
+ jest-diff: 30.1.2
+ pretty-format: 30.0.5
+
+ jest-message-util@30.1.0:
+ dependencies:
+ "@babel/code-frame": 7.27.1
+ "@jest/types": 30.0.5
+ "@types/stack-utils": 2.0.3
+ chalk: 4.1.2
+ graceful-fs: 4.2.11
+ micromatch: 4.0.8
+ pretty-format: 30.0.5
+ slash: 3.0.0
+ stack-utils: 2.0.6
+
+ jest-mock@30.0.5:
+ dependencies:
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ jest-util: 30.0.5
+
+ jest-pnp-resolver@1.2.3(jest-resolve@30.1.3):
+ optionalDependencies:
+ jest-resolve: 30.1.3
+
+ jest-regex-util@30.0.1: {}
+
+ jest-resolve-dependencies@30.1.3:
+ dependencies:
+ jest-regex-util: 30.0.1
+ jest-snapshot: 30.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ jest-resolve@30.1.3:
+ dependencies:
+ chalk: 4.1.2
+ graceful-fs: 4.2.11
+ jest-haste-map: 30.1.0
+ jest-pnp-resolver: 1.2.3(jest-resolve@30.1.3)
+ jest-util: 30.0.5
+ jest-validate: 30.1.0
+ slash: 3.0.0
+ unrs-resolver: 1.11.1
+
+ jest-runner@30.1.3:
+ dependencies:
+ "@jest/console": 30.1.2
+ "@jest/environment": 30.1.2
+ "@jest/test-result": 30.1.3
+ "@jest/transform": 30.1.2
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ chalk: 4.1.2
+ emittery: 0.13.1
+ exit-x: 0.2.2
+ graceful-fs: 4.2.11
+ jest-docblock: 30.0.1
+ jest-environment-node: 30.1.2
+ jest-haste-map: 30.1.0
+ jest-leak-detector: 30.1.0
+ jest-message-util: 30.1.0
+ jest-resolve: 30.1.3
+ jest-runtime: 30.1.3
+ jest-util: 30.0.5
+ jest-watcher: 30.1.3
+ jest-worker: 30.1.0
+ p-limit: 3.1.0
+ source-map-support: 0.5.13
+ transitivePeerDependencies:
+ - supports-color
+
+ jest-runtime@30.1.3:
+ dependencies:
+ "@jest/environment": 30.1.2
+ "@jest/fake-timers": 30.1.2
+ "@jest/globals": 30.1.2
+ "@jest/source-map": 30.0.1
+ "@jest/test-result": 30.1.3
+ "@jest/transform": 30.1.2
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ chalk: 4.1.2
+ cjs-module-lexer: 2.1.0
+ collect-v8-coverage: 1.0.2
+ glob: 10.4.5
+ graceful-fs: 4.2.11
+ jest-haste-map: 30.1.0
+ jest-message-util: 30.1.0
+ jest-mock: 30.0.5
+ jest-regex-util: 30.0.1
+ jest-resolve: 30.1.3
+ jest-snapshot: 30.1.2
+ jest-util: 30.0.5
+ slash: 3.0.0
+ strip-bom: 4.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ jest-snapshot@30.1.2:
+ dependencies:
+ "@babel/core": 7.28.3
+ "@babel/generator": 7.28.0
+ "@babel/plugin-syntax-jsx": 7.27.1(@babel/core@7.28.3)
+ "@babel/plugin-syntax-typescript": 7.27.1(@babel/core@7.28.3)
+ "@babel/types": 7.28.2
+ "@jest/expect-utils": 30.1.2
+ "@jest/get-type": 30.1.0
+ "@jest/snapshot-utils": 30.1.2
+ "@jest/transform": 30.1.2
+ "@jest/types": 30.0.5
+ babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.3)
+ chalk: 4.1.2
+ expect: 30.1.2
+ graceful-fs: 4.2.11
+ jest-diff: 30.1.2
+ jest-matcher-utils: 30.1.2
+ jest-message-util: 30.1.0
+ jest-util: 30.0.5
+ pretty-format: 30.0.5
+ semver: 7.7.2
+ synckit: 0.11.11
+ transitivePeerDependencies:
+ - supports-color
+
jest-util@29.7.0:
dependencies:
"@jest/types": 29.6.3
@@ -11152,6 +13228,35 @@ snapshots:
graceful-fs: 4.2.11
picomatch: 2.3.1
+ jest-util@30.0.5:
+ dependencies:
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ chalk: 4.1.2
+ ci-info: 4.3.0
+ graceful-fs: 4.2.11
+ picomatch: 4.0.3
+
+ jest-validate@30.1.0:
+ dependencies:
+ "@jest/get-type": 30.1.0
+ "@jest/types": 30.0.5
+ camelcase: 6.3.0
+ chalk: 4.1.2
+ leven: 3.1.0
+ pretty-format: 30.0.5
+
+ jest-watcher@30.1.3:
+ dependencies:
+ "@jest/test-result": 30.1.3
+ "@jest/types": 30.0.5
+ "@types/node": 24.2.1
+ ansi-escapes: 4.3.2
+ chalk: 4.1.2
+ emittery: 0.13.1
+ jest-util: 30.0.5
+ string-length: 4.0.2
+
jest-worker@29.7.0:
dependencies:
"@types/node": 24.2.1
@@ -11159,12 +13264,40 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
+ jest-worker@30.1.0:
+ dependencies:
+ "@types/node": 24.2.1
+ "@ungap/structured-clone": 1.3.0
+ jest-util: 30.0.5
+ merge-stream: 2.0.0
+ supports-color: 8.1.1
+
+ jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ dependencies:
+ "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ "@jest/types": 30.0.5
+ import-local: 3.2.0
+ jest-cli: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ transitivePeerDependencies:
+ - "@types/node"
+ - babel-plugin-macros
+ - esbuild-register
+ - supports-color
+ - ts-node
+
jiti@1.21.7: {}
jose@4.15.9: {}
+ js-levenshtein@1.1.6: {}
+
js-tokens@4.0.0: {}
+ js-yaml@3.14.1:
+ dependencies:
+ argparse: 1.0.10
+ esprima: 4.0.1
+
js-yaml@4.1.0:
dependencies:
argparse: 2.0.1
@@ -11192,6 +13325,8 @@ snapshots:
dependencies:
minimist: 1.2.8
+ json5@2.2.3: {}
+
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
@@ -11219,6 +13354,8 @@ snapshots:
dependencies:
language-subtag-registry: 0.3.23
+ leven@3.1.0: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -11248,6 +13385,10 @@ snapshots:
dependencies:
lie: 3.1.1
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -11256,6 +13397,8 @@ snapshots:
lodash.isarguments@3.1.0: {}
+ lodash.memoize@4.1.2: {}
+
lodash.merge@4.6.2: {}
longest-streak@3.1.0: {}
@@ -11266,6 +13409,10 @@ snapshots:
lru-cache@10.4.3: {}
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
@@ -11282,8 +13429,16 @@ snapshots:
dependencies:
semver: 6.3.1
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.7.2
+
make-error@1.3.6: {}
+ makeerror@1.0.12:
+ dependencies:
+ tmpl: 1.0.5
+
math-intrinsics@1.1.0: {}
mdast-util-from-markdown@2.0.2:
@@ -11591,13 +13746,6 @@ snapshots:
mkdirp@1.0.4: {}
- mlly@1.7.4:
- dependencies:
- acorn: 8.15.0
- pathe: 2.0.3
- pkg-types: 1.3.1
- ufo: 1.6.1
-
mri@1.2.0: {}
ms@2.1.1: {}
@@ -11618,13 +13766,13 @@ snapshots:
neo-async@2.6.2: {}
- next-auth@4.24.11(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ next-auth@4.24.11(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
"@babel/runtime": 7.28.2
"@panva/hkdf": 1.2.1
cookie: 0.7.2
jose: 4.15.9
- next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.27.0
@@ -11638,7 +13786,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0):
+ next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0):
dependencies:
"@next/env": 14.2.31
"@swc/helpers": 0.5.5
@@ -11648,7 +13796,7 @@ snapshots:
postcss: 8.4.31
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- styled-jsx: 5.1.1(react@18.3.1)
+ styled-jsx: 5.1.1(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1)
optionalDependencies:
"@next/swc-darwin-arm64": 14.2.31
"@next/swc-darwin-x64": 14.2.31
@@ -11664,13 +13812,9 @@ snapshots:
- "@babel/core"
- babel-plugin-macros
- node-abort-controller@3.1.1: {}
-
node-addon-api@7.1.1:
optional: true
- node-fetch-native@1.6.7: {}
-
node-fetch@2.6.7:
dependencies:
whatwg-url: 5.0.0
@@ -11685,6 +13829,8 @@ snapshots:
node-gyp-build@4.8.4: {}
+ node-int64@0.4.0: {}
+
node-releases@2.0.19: {}
nopt@5.0.0:
@@ -11706,21 +13852,12 @@ snapshots:
gauge: 3.0.2
set-blocking: 2.0.0
- nuqs@2.4.3(next@14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1):
+ nuqs@2.4.3(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1):
dependencies:
mitt: 3.0.1
react: 18.3.1
optionalDependencies:
- next: 14.2.31(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
-
- nypm@0.5.4:
- dependencies:
- citty: 0.1.6
- consola: 3.4.2
- pathe: 2.0.3
- pkg-types: 1.3.1
- tinyexec: 0.3.2
- ufo: 1.6.1
+ next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
oauth@0.9.15: {}
@@ -11770,8 +13907,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
- ohash@1.1.6: {}
-
oidc-token-hash@5.1.1: {}
once@1.3.3:
@@ -11786,6 +13921,28 @@ snapshots:
dependencies:
mimic-fn: 2.1.0
+ openapi-fetch@0.14.0:
+ dependencies:
+ openapi-typescript-helpers: 0.0.15
+
+ openapi-react-query@0.5.0(@tanstack/react-query@5.85.9(react@18.3.1))(openapi-fetch@0.14.0):
+ dependencies:
+ "@tanstack/react-query": 5.85.9(react@18.3.1)
+ openapi-fetch: 0.14.0
+ openapi-typescript-helpers: 0.0.15
+
+ openapi-typescript-helpers@0.0.15: {}
+
+ openapi-typescript@7.9.1(typescript@5.9.2):
+ dependencies:
+ "@redocly/openapi-core": 1.34.5(supports-color@10.2.0)
+ ansi-colors: 4.1.3
+ change-case: 5.4.4
+ parse-json: 8.3.0
+ supports-color: 10.2.0
+ typescript: 5.9.2
+ yargs-parser: 21.1.1
+
openid-client@5.7.1:
dependencies:
jose: 4.15.9
@@ -11812,14 +13969,24 @@ snapshots:
p-finally@2.0.1: {}
+ p-limit@2.3.0:
+ dependencies:
+ p-try: 2.2.0
+
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
+ p-try@2.2.0: {}
+
package-json-from-dist@1.0.1: {}
parent-module@1.0.1:
@@ -11843,6 +14010,12 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
+ parse-json@8.3.0:
+ dependencies:
+ "@babel/code-frame": 7.27.1
+ index-to-position: 1.1.0
+ type-fest: 4.41.0
+
parse-ms@2.1.0: {}
path-browserify@1.0.1: {}
@@ -11875,14 +14048,8 @@ snapshots:
path-type@4.0.0: {}
- pathe@1.1.2: {}
-
- pathe@2.0.3: {}
-
pend@1.2.0: {}
- perfect-debounce@1.0.0: {}
-
perfect-freehand@1.2.2: {}
picocolors@1.0.0: {}
@@ -11897,11 +14064,11 @@ snapshots:
pirates@4.0.7: {}
- pkg-types@1.3.1:
+ pkg-dir@4.2.0:
dependencies:
- confbox: 0.1.8
- mlly: 1.7.4
- pathe: 2.0.3
+ find-up: 4.1.0
+
+ pluralize@8.0.0: {}
possible-typed-array-names@1.1.0: {}
@@ -11962,6 +14129,12 @@ snapshots:
pretty-format@3.8.0: {}
+ pretty-format@30.0.5:
+ dependencies:
+ "@jest/schemas": 30.0.5
+ ansi-styles: 5.2.0
+ react-is: 18.3.1
+
pretty-ms@7.0.1:
dependencies:
parse-ms: 2.1.0
@@ -11993,6 +14166,8 @@ snapshots:
punycode@2.3.1: {}
+ pure-rand@7.0.1: {}
+
qr.js@0.0.0: {}
queue-microtask@1.2.3: {}
@@ -12008,11 +14183,6 @@ snapshots:
iconv-lite: 0.4.24
unpipe: 1.0.0
- rc9@2.1.2:
- dependencies:
- defu: 6.1.4
- destr: 2.0.5
-
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -12031,6 +14201,8 @@ snapshots:
react-is@16.13.1: {}
+ react-is@18.3.1: {}
+
react-markdown@9.1.0(@types/react@18.2.20)(react@18.3.1):
dependencies:
"@types/hast": 3.0.4
@@ -12118,10 +14290,6 @@ snapshots:
dependencies:
redis-errors: 1.2.0
- redlock@5.0.0-beta.2:
- dependencies:
- node-abort-controller: 3.1.1
-
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
@@ -12165,12 +14333,18 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
+ require-directory@2.1.1: {}
+
require-from-string@2.0.2: {}
reraf@1.1.1: {}
reselect@5.1.1: {}
+ resolve-cwd@3.0.0:
+ dependencies:
+ resolve-from: 5.0.0
+
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@@ -12339,6 +14513,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ slash@3.0.0: {}
+
socket.io-client@4.7.2:
dependencies:
"@socket.io/component-emitter": 3.1.2
@@ -12359,16 +14535,27 @@ snapshots:
source-map-js@1.2.1: {}
+ source-map-support@0.5.13:
+ dependencies:
+ buffer-from: 1.1.2
+ source-map: 0.6.1
+
source-map@0.5.7: {}
source-map@0.6.1: {}
space-separated-tokens@2.0.2: {}
+ sprintf-js@1.0.3: {}
+
sprintf-js@1.1.3: {}
stable-hash@0.0.5: {}
+ stack-utils@2.0.6:
+ dependencies:
+ escape-string-regexp: 2.0.0
+
stacktrace-parser@0.1.11:
dependencies:
type-fest: 0.7.1
@@ -12396,6 +14583,11 @@ snapshots:
streamsearch@1.1.0: {}
+ string-length@4.0.2:
+ dependencies:
+ char-regex: 1.0.2
+ strip-ansi: 6.0.1
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -12477,6 +14669,8 @@ snapshots:
strip-bom@3.0.0: {}
+ strip-bom@4.0.0: {}
+
strip-final-newline@2.0.0: {}
strip-json-comments@3.1.1: {}
@@ -12489,10 +14683,13 @@ snapshots:
dependencies:
inline-style-parser: 0.2.4
- styled-jsx@5.1.1(react@18.3.1):
+ styled-jsx@5.1.1(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
react: 18.3.1
+ optionalDependencies:
+ "@babel/core": 7.28.3
+ babel-plugin-macros: 3.1.0
stylis@4.2.0: {}
@@ -12506,6 +14703,8 @@ snapshots:
pirates: 4.0.7
ts-interface-checker: 0.1.13
+ supports-color@10.2.0: {}
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -12518,6 +14717,10 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ synckit@0.11.11:
+ dependencies:
+ "@pkgr/core": 0.2.9
+
tailwindcss@3.4.17(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
dependencies:
"@alloc/quick-lru": 5.2.0
@@ -12564,6 +14767,12 @@ snapshots:
mkdirp: 1.0.4
yallist: 4.0.0
+ test-exclude@6.0.0:
+ dependencies:
+ "@istanbuljs/schema": 0.1.3
+ glob: 7.2.3
+ minimatch: 3.1.2
+
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -12576,13 +14785,13 @@ snapshots:
dependencies:
convert-hrtime: 3.0.0
- tinyexec@0.3.2: {}
-
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.6(picomatch@4.0.3)
picomatch: 4.0.3
+ tmpl@1.0.5: {}
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -12603,6 +14812,26 @@ snapshots:
ts-interface-checker@0.1.13: {}
+ ts-jest@29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)))(typescript@5.9.2):
+ dependencies:
+ bs-logger: 0.2.6
+ fast-json-stable-stringify: 2.1.0
+ handlebars: 4.7.8
+ jest: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ json5: 2.2.3
+ lodash.memoize: 4.1.2
+ make-error: 1.3.6
+ semver: 7.7.2
+ type-fest: 4.41.0
+ typescript: 5.9.2
+ yargs-parser: 21.1.1
+ optionalDependencies:
+ "@babel/core": 7.28.3
+ "@jest/transform": 30.1.2
+ "@jest/types": 30.0.5
+ babel-jest: 30.1.2(@babel/core@7.28.3)
+ jest-util: 30.0.5
+
ts-morph@12.0.0:
dependencies:
"@ts-morph/common": 0.11.1
@@ -12660,8 +14889,14 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
+ type-detect@4.0.8: {}
+
+ type-fest@0.21.3: {}
+
type-fest@0.7.1: {}
+ type-fest@4.41.0: {}
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -12717,8 +14952,6 @@ snapshots:
udomdiff@1.1.2: {}
- ufo@1.6.1: {}
-
uglify-js@3.19.3:
optional: true
@@ -12739,8 +14972,6 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
- uncrypto@0.1.3: {}
-
undici-types@7.10.0: {}
undici@5.28.4:
@@ -12818,6 +15049,8 @@ snapshots:
uqr@0.1.2: {}
+ uri-js-replace@1.0.1: {}
+
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
@@ -12853,6 +15086,12 @@ snapshots:
v8-compile-cache-lib@3.0.1: {}
+ v8-to-istanbul@9.3.0:
+ dependencies:
+ "@jridgewell/trace-mapping": 0.3.30
+ "@types/istanbul-lib-coverage": 2.0.6
+ convert-source-map: 2.0.0
+
vercel@37.14.0:
dependencies:
"@vercel/build-utils": 8.4.12
@@ -12883,6 +15122,10 @@ snapshots:
"@types/unist": 3.0.3
vfile-message: 4.0.3
+ walker@1.0.8:
+ dependencies:
+ makeerror: 1.0.12
+
wavesurfer.js@7.10.1: {}
web-vitals@0.2.4: {}
@@ -12967,6 +15210,11 @@ snapshots:
wrappy@1.0.2: {}
+ write-file-atomic@5.0.1:
+ dependencies:
+ imurmurhash: 0.1.4
+ signal-exit: 4.1.0
+
ws@8.17.1: {}
xdg-app-paths@5.1.0:
@@ -12979,14 +15227,30 @@ snapshots:
xmlhttprequest-ssl@2.0.0: {}
+ y18n@5.0.8: {}
+
yallist@3.1.1: {}
yallist@4.0.0: {}
+ yaml-ast-parser@0.0.43: {}
+
yaml@1.10.2: {}
yaml@2.8.1: {}
+ yargs-parser@21.1.1: {}
+
+ yargs@17.7.2:
+ dependencies:
+ cliui: 8.0.1
+ escalade: 3.2.0
+ get-caller-file: 2.0.5
+ require-directory: 2.1.1
+ string-width: 4.2.3
+ y18n: 5.0.8
+ yargs-parser: 21.1.1
+
yauzl-clone@1.0.4:
dependencies:
events-intercept: 2.0.0
@@ -13005,4 +15269,6 @@ snapshots:
yocto-queue@0.1.0: {}
+ zod@4.1.5: {}
+
zwitch@2.0.4: {}
diff --git a/www/public/service-worker.js b/www/public/service-worker.js
index 109561d5..e798e369 100644
--- a/www/public/service-worker.js
+++ b/www/public/service-worker.js
@@ -1,4 +1,4 @@
-let authToken = ""; // Variable to store the token
+let authToken = null;
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SET_AUTH_TOKEN") {
From 08d88ec349f38b0d13e0fa4cb73486c8dfd31836 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Fri, 5 Sep 2025 18:39:32 -0400
Subject: [PATCH 16/77] fix: kv use tls explicit (#610)
Co-authored-by: Igor Loskutov
---
www/app/lib/redisClient.ts | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/www/app/lib/redisClient.ts b/www/app/lib/redisClient.ts
index 1be36538..753a0561 100644
--- a/www/app/lib/redisClient.ts
+++ b/www/app/lib/redisClient.ts
@@ -3,6 +3,10 @@ import { isBuildPhase } from "./next";
export type RedisClient = Pick;
+const KV_USE_TLS = process.env.KV_USE_TLS
+ ? process.env.KV_USE_TLS === "true"
+ : undefined;
+
const getRedisClient = (): RedisClient => {
const redisUrl = process.env.KV_URL;
if (!redisUrl) {
@@ -11,6 +15,11 @@ const getRedisClient = (): RedisClient => {
const redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
lazyConnect: true,
+ ...(KV_USE_TLS === true
+ ? {
+ tls: {},
+ }
+ : {}),
});
redis.on("error", (error) => {
From 7f5a4c9ddc7fd098860c8bdda2ca3b57f63ded2f Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Fri, 5 Sep 2025 23:03:24 -0400
Subject: [PATCH 17/77] fix: token refresh locking (#613)
* fix: kv use tls explicit
* fix: token refresh locking
* remove logs
* compile fix
* compile fix
---------
Co-authored-by: Igor Loskutov
---
www/app/lib/AuthProvider.tsx | 8 ++-
www/app/lib/SessionAutoRefresh.tsx | 22 +++----
www/app/lib/authBackend.ts | 101 +++++++++++++++++++----------
www/app/lib/redisClient.ts | 47 ++++++++++----
www/app/lib/redisTokenCache.ts | 6 +-
www/app/lib/useUserName.ts | 3 +-
www/app/lib/utils.ts | 11 ++++
www/package.json | 1 +
www/pnpm-lock.yaml | 22 +++++++
9 files changed, 159 insertions(+), 62 deletions(-)
diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx
index 96f49f87..6c09926b 100644
--- a/www/app/lib/AuthProvider.tsx
+++ b/www/app/lib/AuthProvider.tsx
@@ -8,10 +8,11 @@ import { assertCustomSession, CustomSession } from "./types";
import { Session } from "next-auth";
import { SessionAutoRefresh } from "./SessionAutoRefresh";
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
+import { assertExists } from "./utils";
type AuthContextType = (
| { status: "loading" }
- | { status: "refreshing" }
+ | { status: "refreshing"; user: CustomSession["user"] }
| { status: "unauthenticated"; error?: string }
| {
status: "authenticated";
@@ -41,7 +42,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
return { status };
}
case true: {
- return { status: "refreshing" as const };
+ return {
+ status: "refreshing" as const,
+ user: assertExists(customSession).user,
+ };
}
default: {
const _: never = sessionIsHere;
diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx
index fd29367f..3729db8c 100644
--- a/www/app/lib/SessionAutoRefresh.tsx
+++ b/www/app/lib/SessionAutoRefresh.tsx
@@ -15,6 +15,7 @@ const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE;
export function SessionAutoRefresh({ children }) {
const auth = useAuth();
+
const accessTokenExpires =
auth.status === "authenticated" ? auth.accessTokenExpires : null;
@@ -23,17 +24,16 @@ export function SessionAutoRefresh({ children }) {
// and not too slow (debuggable)
const INTERVAL_REFRESH_MS = 5000;
const interval = setInterval(() => {
- if (accessTokenExpires !== null) {
- const timeLeft = accessTokenExpires - Date.now();
- if (timeLeft < REFRESH_BEFORE) {
- auth
- .update()
- .then(() => {})
- .catch((e) => {
- // note: 401 won't be considered error here
- console.error("error refreshing auth token", e);
- });
- }
+ if (accessTokenExpires === null) return;
+ const timeLeft = accessTokenExpires - Date.now();
+ if (timeLeft < REFRESH_BEFORE) {
+ auth
+ .update()
+ .then(() => {})
+ .catch((e) => {
+ // note: 401 won't be considered error here
+ console.error("error refreshing auth token", e);
+ });
}
}, INTERVAL_REFRESH_MS);
diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts
index af93b274..0b48f613 100644
--- a/www/app/lib/authBackend.ts
+++ b/www/app/lib/authBackend.ts
@@ -2,7 +2,11 @@ import { AuthOptions } from "next-auth";
import AuthentikProvider from "next-auth/providers/authentik";
import type { JWT } from "next-auth/jwt";
import { JWTWithAccessToken, CustomSession } from "./types";
-import { assertExists, assertExistsAndNonEmptyString } from "./utils";
+import {
+ assertExists,
+ assertExistsAndNonEmptyString,
+ assertNotExists,
+} from "./utils";
import {
REFRESH_ACCESS_TOKEN_BEFORE,
REFRESH_ACCESS_TOKEN_ERROR,
@@ -12,14 +16,10 @@ import {
setTokenCache,
deleteTokenCache,
} from "./redisTokenCache";
-import { tokenCacheRedis } from "./redisClient";
+import { tokenCacheRedis, redlock } from "./redisClient";
import { isBuildPhase } from "./next";
-// REFRESH_ACCESS_TOKEN_BEFORE because refresh is based on access token expiration (imagine we cache it 30 days)
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
-
-const refreshLocks = new Map>();
-
const CLIENT_ID = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID)
: "noop";
@@ -45,31 +45,48 @@ export const authOptions: AuthOptions = {
},
callbacks: {
async jwt({ token, account, user }) {
- const KEY = `token:${token.sub}`;
+ if (account && !account.access_token) {
+ await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
+ }
if (account && user) {
// called only on first login
// XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
- const expiresAtS = assertExists(account.expires_at);
- const expiresAtMs = expiresAtS * 1000;
- if (!account.access_token) {
- await deleteTokenCache(tokenCacheRedis, KEY);
- } else {
+ if (account.access_token) {
+ const expiresAtS = assertExists(account.expires_at);
+ const expiresAtMs = expiresAtS * 1000;
const jwtToken: JWTWithAccessToken = {
...token,
accessToken: account.access_token,
accessTokenExpires: expiresAtMs,
refreshToken: account.refresh_token,
};
- await setTokenCache(tokenCacheRedis, KEY, {
- token: jwtToken,
- timestamp: Date.now(),
- });
- return jwtToken;
+ if (jwtToken.error) {
+ await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
+ } else {
+ assertNotExists(
+ jwtToken.error,
+ `panic! trying to cache token with error in jwt: ${jwtToken.error}`,
+ );
+ await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
+ token: jwtToken,
+ timestamp: Date.now(),
+ });
+ return jwtToken;
+ }
}
}
- const currentToken = await getTokenCache(tokenCacheRedis, KEY);
+ const currentToken = await getTokenCache(
+ tokenCacheRedis,
+ `token:${token.sub}`,
+ );
+ console.debug(
+ "currentToken from cache",
+ JSON.stringify(currentToken, null, 2),
+ "will be returned?",
+ currentToken && Date.now() < currentToken.token.accessTokenExpires,
+ );
if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
return currentToken.token;
}
@@ -97,20 +114,22 @@ export const authOptions: AuthOptions = {
async function lockedRefreshAccessToken(
token: JWT,
): Promise {
- const lockKey = `${token.sub}-refresh`;
+ const lockKey = `${token.sub}-lock`;
- const existingRefresh = refreshLocks.get(lockKey);
- if (existingRefresh) {
- return await existingRefresh;
- }
-
- const refreshPromise = (async () => {
- try {
+ return redlock
+ .using([lockKey], 10000, async () => {
const cached = await getTokenCache(tokenCacheRedis, `token:${token.sub}`);
+ if (cached)
+ console.debug(
+ "received cached token. to delete?",
+ Date.now() - cached.timestamp > TOKEN_CACHE_TTL,
+ );
+ else console.debug("no cached token received");
if (cached) {
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
} else if (Date.now() < cached.token.accessTokenExpires) {
+ console.debug("returning cached token", cached.token);
return cached.token;
}
}
@@ -118,19 +137,35 @@ async function lockedRefreshAccessToken(
const currentToken = cached?.token || (token as JWTWithAccessToken);
const newToken = await refreshAccessToken(currentToken);
+ console.debug("current token during refresh", currentToken);
+ console.debug("new token during refresh", newToken);
+
+ if (newToken.error) {
+ await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
+ return newToken;
+ }
+
+ assertNotExists(
+ newToken.error,
+ `panic! trying to cache token with error during refresh: ${newToken.error}`,
+ );
await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
token: newToken,
timestamp: Date.now(),
});
return newToken;
- } finally {
- setTimeout(() => refreshLocks.delete(lockKey), 100);
- }
- })();
-
- refreshLocks.set(lockKey, refreshPromise);
- return refreshPromise;
+ })
+ .catch((e) => {
+ console.error("error refreshing token", e);
+ deleteTokenCache(tokenCacheRedis, `token:${token.sub}`).catch((e) => {
+ console.error("error deleting errored token", e);
+ });
+ return {
+ ...token,
+ error: REFRESH_ACCESS_TOKEN_ERROR,
+ } as JWTWithAccessToken;
+ });
}
async function refreshAccessToken(token: JWT): Promise {
diff --git a/www/app/lib/redisClient.ts b/www/app/lib/redisClient.ts
index 753a0561..aeb3595b 100644
--- a/www/app/lib/redisClient.ts
+++ b/www/app/lib/redisClient.ts
@@ -1,20 +1,29 @@
import Redis from "ioredis";
import { isBuildPhase } from "./next";
+import Redlock, { ResourceLockedError } from "redlock";
export type RedisClient = Pick;
-
+export type RedlockClient = {
+ using: (
+ keys: string | string[],
+ ttl: number,
+ cb: () => Promise,
+ ) => Promise;
+};
const KV_USE_TLS = process.env.KV_USE_TLS
? process.env.KV_USE_TLS === "true"
: undefined;
+let redisClient: Redis | null = null;
+
const getRedisClient = (): RedisClient => {
+ if (redisClient) return redisClient;
const redisUrl = process.env.KV_URL;
if (!redisUrl) {
throw new Error("KV_URL environment variable is required");
}
- const redis = new Redis(redisUrl, {
+ redisClient = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
- lazyConnect: true,
...(KV_USE_TLS === true
? {
tls: {},
@@ -22,18 +31,11 @@ const getRedisClient = (): RedisClient => {
: {}),
});
- redis.on("error", (error) => {
+ redisClient.on("error", (error) => {
console.error("Redis error:", error);
});
- // not necessary but will indicate redis config errors by failfast at startup
- // happens only once; after that connection is allowed to die and the lib is assumed to be able to restore it eventually
- redis.connect().catch((e) => {
- console.error("Failed to connect to Redis:", e);
- process.exit(1);
- });
-
- return redis;
+ return redisClient;
};
// next.js buildtime usage - we want to isolate next.js "build" time concepts here
@@ -52,4 +54,25 @@ const noopClient: RedisClient = (() => {
del: noopDel,
};
})();
+
+const noopRedlock: RedlockClient = {
+ using: (resource: string | string[], ttl: number, cb: () => Promise) =>
+ cb(),
+};
+
+export const redlock: RedlockClient = isBuildPhase
+ ? noopRedlock
+ : (() => {
+ const r = new Redlock([getRedisClient()], {});
+ r.on("error", (error) => {
+ if (error instanceof ResourceLockedError) {
+ return;
+ }
+
+ // Log all other errors.
+ console.error(error);
+ });
+ return r;
+ })();
+
export const tokenCacheRedis = isBuildPhase ? noopClient : getRedisClient();
diff --git a/www/app/lib/redisTokenCache.ts b/www/app/lib/redisTokenCache.ts
index 4fa4e304..a8b720ef 100644
--- a/www/app/lib/redisTokenCache.ts
+++ b/www/app/lib/redisTokenCache.ts
@@ -9,7 +9,6 @@ const TokenCacheEntrySchema = z.object({
accessToken: z.string(),
accessTokenExpires: z.number(),
refreshToken: z.string().optional(),
- error: z.string().optional(),
}),
timestamp: z.number(),
});
@@ -46,14 +45,15 @@ export async function getTokenCache(
}
}
+const TTL_SECONDS = 30 * 24 * 60 * 60;
+
export async function setTokenCache(
redis: KV,
key: string,
value: TokenCacheEntry,
): Promise {
const encodedValue = TokenCacheEntryCodec.encode(value);
- const ttlSeconds = Math.floor(REFRESH_ACCESS_TOKEN_BEFORE / 1000);
- await redis.setex(key, ttlSeconds, encodedValue);
+ await redis.setex(key, TTL_SECONDS, encodedValue);
}
export async function deleteTokenCache(redis: KV, key: string): Promise {
diff --git a/www/app/lib/useUserName.ts b/www/app/lib/useUserName.ts
index 80814281..46850176 100644
--- a/www/app/lib/useUserName.ts
+++ b/www/app/lib/useUserName.ts
@@ -2,6 +2,7 @@ import { useAuth } from "./AuthProvider";
export const useUserName = (): string | null | undefined => {
const auth = useAuth();
- if (auth.status !== "authenticated") return undefined;
+ if (auth.status !== "authenticated" && auth.status !== "refreshing")
+ return undefined;
return auth.user?.name || null;
};
diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts
index 122ab234..8e8651ff 100644
--- a/www/app/lib/utils.ts
+++ b/www/app/lib/utils.ts
@@ -158,6 +158,17 @@ export const assertExists = (
return value;
};
+export const assertNotExists = (
+ value: T | null | undefined,
+ err?: string,
+): void => {
+ if (value !== null && value !== undefined) {
+ throw new Error(
+ `Assertion failed: ${err ?? "value is not null or undefined"}`,
+ );
+ }
+};
+
export const assertExistsAndNonEmptyString = (
value: string | null | undefined,
): NonEmptyString =>
diff --git a/www/package.json b/www/package.json
index b7511147..27e30a5f 100644
--- a/www/package.json
+++ b/www/package.json
@@ -45,6 +45,7 @@
"react-markdown": "^9.0.0",
"react-qr-code": "^2.0.12",
"react-select-search": "^4.1.7",
+ "redlock": "5.0.0-beta.2",
"sass": "^1.63.6",
"simple-peer": "^9.11.1",
"tailwindcss": "^3.3.2",
diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml
index 14b42c55..f4346855 100644
--- a/www/pnpm-lock.yaml
+++ b/www/pnpm-lock.yaml
@@ -106,6 +106,9 @@ importers:
react-select-search:
specifier: ^4.1.7
version: 4.1.8(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ redlock:
+ specifier: 5.0.0-beta.2
+ version: 5.0.0-beta.2
sass:
specifier: ^1.63.6
version: 1.90.0
@@ -6566,6 +6569,12 @@ packages:
sass:
optional: true
+ node-abort-controller@3.1.1:
+ resolution:
+ {
+ integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==,
+ }
+
node-addon-api@7.1.1:
resolution:
{
@@ -7433,6 +7442,13 @@ packages:
}
engines: { node: ">=4" }
+ redlock@5.0.0-beta.2:
+ resolution:
+ {
+ integrity: sha512-2RDWXg5jgRptDrB1w9O/JgSZC0j7y4SlaXnor93H/UJm/QyDiFgBKNtrh0TI6oCXqYSaSoXxFh6Sd3VtYfhRXw==,
+ }
+ engines: { node: ">=12" }
+
redux-thunk@3.1.0:
resolution:
{
@@ -13812,6 +13828,8 @@ snapshots:
- "@babel/core"
- babel-plugin-macros
+ node-abort-controller@3.1.1: {}
+
node-addon-api@7.1.1:
optional: true
@@ -14290,6 +14308,10 @@ snapshots:
dependencies:
redis-errors: 1.2.0
+ redlock@5.0.0-beta.2:
+ dependencies:
+ node-abort-controller: 3.1.1
+
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
From 02a3938822f7167125bc8a3a250eda6ea850f273 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Fri, 5 Sep 2025 22:50:10 -0600
Subject: [PATCH 18/77] chore(main): release 0.9.0 (#603)
---
CHANGELOG.md | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 433691e9..987a6579 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06)
+
+
+### Features
+
+* frontend openapi react query ([#606](https://github.com/Monadical-SAS/reflector/issues/606)) ([c4d2825](https://github.com/Monadical-SAS/reflector/commit/c4d2825c81f81ad8835629fbf6ea8c7383f8c31b))
+
+
+### Bug Fixes
+
+* align whisper transcriber api with parakeet ([#602](https://github.com/Monadical-SAS/reflector/issues/602)) ([0663700](https://github.com/Monadical-SAS/reflector/commit/0663700a615a4af69a03c96c410f049e23ec9443))
+* kv use tls explicit ([#610](https://github.com/Monadical-SAS/reflector/issues/610)) ([08d88ec](https://github.com/Monadical-SAS/reflector/commit/08d88ec349f38b0d13e0fa4cb73486c8dfd31836))
+* source kind for file processing ([#601](https://github.com/Monadical-SAS/reflector/issues/601)) ([dc82f8b](https://github.com/Monadical-SAS/reflector/commit/dc82f8bb3bdf3ab3d4088e592a30fd63907319e1))
+* token refresh locking ([#613](https://github.com/Monadical-SAS/reflector/issues/613)) ([7f5a4c9](https://github.com/Monadical-SAS/reflector/commit/7f5a4c9ddc7fd098860c8bdda2ca3b57f63ded2f))
+
## [0.8.2](https://github.com/Monadical-SAS/reflector/compare/v0.8.1...v0.8.2) (2025-08-29)
From 5a5b3233820df9536da75e87ce6184a983d4713a Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Mon, 8 Sep 2025 10:40:18 -0400
Subject: [PATCH 19/77] fix: sync backend and frontend token refresh logic
(#614)
* sync backend and frontend token refresh logic
* return react strict mode
---------
Co-authored-by: Igor Loskutov
---
www/app/lib/SessionAutoRefresh.tsx | 7 ++-----
www/app/lib/auth.ts | 5 +++++
www/app/lib/authBackend.ts | 11 ++++++++---
3 files changed, 15 insertions(+), 8 deletions(-)
diff --git a/www/app/lib/SessionAutoRefresh.tsx b/www/app/lib/SessionAutoRefresh.tsx
index 3729db8c..6b26077d 100644
--- a/www/app/lib/SessionAutoRefresh.tsx
+++ b/www/app/lib/SessionAutoRefresh.tsx
@@ -9,9 +9,7 @@
import { useEffect } from "react";
import { useAuth } from "./AuthProvider";
-import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth";
-
-const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE;
+import { shouldRefreshToken } from "./auth";
export function SessionAutoRefresh({ children }) {
const auth = useAuth();
@@ -25,8 +23,7 @@ export function SessionAutoRefresh({ children }) {
const INTERVAL_REFRESH_MS = 5000;
const interval = setInterval(() => {
if (accessTokenExpires === null) return;
- const timeLeft = accessTokenExpires - Date.now();
- if (timeLeft < REFRESH_BEFORE) {
+ if (shouldRefreshToken(accessTokenExpires)) {
auth
.update()
.then(() => {})
diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts
index f6e60513..c83db264 100644
--- a/www/app/lib/auth.ts
+++ b/www/app/lib/auth.ts
@@ -2,6 +2,11 @@ export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
+export const shouldRefreshToken = (accessTokenExpires: number): boolean => {
+ const timeLeft = accessTokenExpires - Date.now();
+ return timeLeft < REFRESH_ACCESS_TOKEN_BEFORE;
+};
+
export const LOGIN_REQUIRED_PAGES = [
"/transcripts/[!new]",
"/browse(.*)",
diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts
index 0b48f613..06bddff2 100644
--- a/www/app/lib/authBackend.ts
+++ b/www/app/lib/authBackend.ts
@@ -10,6 +10,7 @@ import {
import {
REFRESH_ACCESS_TOKEN_BEFORE,
REFRESH_ACCESS_TOKEN_ERROR,
+ shouldRefreshToken,
} from "./auth";
import {
getTokenCache,
@@ -85,9 +86,13 @@ export const authOptions: AuthOptions = {
"currentToken from cache",
JSON.stringify(currentToken, null, 2),
"will be returned?",
- currentToken && Date.now() < currentToken.token.accessTokenExpires,
+ currentToken &&
+ !shouldRefreshToken(currentToken.token.accessTokenExpires),
);
- if (currentToken && Date.now() < currentToken.token.accessTokenExpires) {
+ if (
+ currentToken &&
+ !shouldRefreshToken(currentToken.token.accessTokenExpires)
+ ) {
return currentToken.token;
}
@@ -128,7 +133,7 @@ async function lockedRefreshAccessToken(
if (cached) {
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
- } else if (Date.now() < cached.token.accessTokenExpires) {
+ } else if (!shouldRefreshToken(cached.token.accessTokenExpires)) {
console.debug("returning cached token", cached.token);
return cached.token;
}
From f81fe9948a9237b3e0001b2d8ca84f54d76878f9 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Tue, 9 Sep 2025 10:50:29 -0400
Subject: [PATCH 20/77] fix: anonymous users transcript permissions (#621)
* fix: public transcript visibility
* fix: transcript permissions frontend
* dead code removal
* chore: remove unused code
* fix search tests
* fix search tests
---------
Co-authored-by: Igor Loskutov
---
server/reflector/db/search.py | 4 +-
server/reflector/views/rooms.py | 12 ++--
server/reflector/views/transcripts.py | 2 -
server/tests/test_search.py | 16 ++---
server/tests/test_search_long_summary.py | 4 +-
.../_components/TranscriptStatusIcon.tsx | 3 +-
.../[transcriptId]/_components/TopicList.tsx | 3 +-
.../[transcriptId]/correct/page.tsx | 12 ++--
.../(app)/transcripts/[transcriptId]/page.tsx | 35 ++++++----
.../[transcriptId]/record/page.tsx | 13 ++--
.../[transcriptId]/upload/page.tsx | 16 ++---
www/app/(app)/transcripts/recorder.tsx | 3 +-
www/app/(app)/transcripts/useTranscript.ts | 69 -------------------
www/app/(app)/transcripts/useWebSockets.ts | 4 +-
www/app/(app)/transcripts/webSocketTypes.ts | 3 +-
www/app/lib/apiHooks.ts | 16 ++---
www/app/lib/transcript.ts | 5 ++
www/app/reflector-api.d.ts | 13 +++-
18 files changed, 90 insertions(+), 143 deletions(-)
delete mode 100644 www/app/(app)/transcripts/useTranscript.ts
create mode 100644 www/app/lib/transcript.ts
diff --git a/server/reflector/db/search.py b/server/reflector/db/search.py
index 66a25ccf..caa21c65 100644
--- a/server/reflector/db/search.py
+++ b/server/reflector/db/search.py
@@ -23,7 +23,7 @@ from pydantic import (
from reflector.db import get_database
from reflector.db.rooms import rooms
-from reflector.db.transcripts import SourceKind, transcripts
+from reflector.db.transcripts import SourceKind, TranscriptStatus, transcripts
from reflector.db.utils import is_postgresql
from reflector.logger import logger
from reflector.utils.string import NonEmptyString, try_parse_non_empty_string
@@ -161,7 +161,7 @@ class SearchResult(BaseModel):
room_name: str | None = None
source_kind: SourceKind
created_at: datetime
- status: str = Field(..., min_length=1)
+ status: TranscriptStatus = Field(..., min_length=1)
rank: float = Field(..., ge=0, le=1)
duration: NonNegativeFloat | None = Field(..., description="Duration in seconds")
search_snippets: list[str] = Field(
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index cc00f3c0..38b611d6 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -215,14 +215,10 @@ async def rooms_create_meeting(
except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError):
# Another request already created a meeting for this room
# Log this race condition occurrence
- logger.info(
- "Race condition detected for room %s - fetching existing meeting",
- room.name,
- )
logger.warning(
- "Whereby meeting %s was created but not used (resource leak) for room %s",
- whereby_meeting["meetingId"],
+ "Race condition detected for room %s and meeting %s - fetching existing meeting",
room.name,
+ whereby_meeting["meetingId"],
)
# Fetch the meeting that was created by the other request
@@ -232,7 +228,9 @@ async def rooms_create_meeting(
if meeting is None:
# Edge case: meeting was created but expired/deleted between checks
logger.error(
- "Meeting disappeared after race condition for room %s", room.name
+ "Meeting disappeared after race condition for room %s",
+ room.name,
+ exc_info=True,
)
raise HTTPException(
status_code=503, detail="Unable to join meeting - please try again"
diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py
index 9acfcbf8..ed2445ae 100644
--- a/server/reflector/views/transcripts.py
+++ b/server/reflector/views/transcripts.py
@@ -350,8 +350,6 @@ async def transcript_update(
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
- if not transcript:
- raise HTTPException(status_code=404, detail="Transcript not found")
values = info.dict(exclude_unset=True)
updated_transcript = await transcripts_controller.update(transcript, values)
return updated_transcript
diff --git a/server/tests/test_search.py b/server/tests/test_search.py
index 0f5c8923..82890080 100644
--- a/server/tests/test_search.py
+++ b/server/tests/test_search.py
@@ -58,7 +58,7 @@ async def test_empty_transcript_title_only_match():
"id": test_id,
"name": "Empty Transcript",
"title": "Empty Meeting",
- "status": "completed",
+ "status": "ended",
"locked": False,
"duration": 0.0,
"created_at": datetime.now(timezone.utc),
@@ -109,7 +109,7 @@ async def test_search_with_long_summary():
"id": test_id,
"name": "Test Long Summary",
"title": "Regular Meeting",
- "status": "completed",
+ "status": "ended",
"locked": False,
"duration": 1800.0,
"created_at": datetime.now(timezone.utc),
@@ -165,7 +165,7 @@ async def test_postgresql_search_with_data():
"id": test_id,
"name": "Test Search Transcript",
"title": "Engineering Planning Meeting Q4 2024",
- "status": "completed",
+ "status": "ended",
"locked": False,
"duration": 1800.0,
"created_at": datetime.now(timezone.utc),
@@ -221,7 +221,7 @@ We need to implement PostgreSQL tsvector for better performance.""",
test_result = next((r for r in results if r.id == test_id), None)
if test_result:
assert test_result.title == "Engineering Planning Meeting Q4 2024"
- assert test_result.status == "completed"
+ assert test_result.status == "ended"
assert test_result.duration == 1800.0
assert 0 <= test_result.rank <= 1, "Rank should be normalized to 0-1"
@@ -268,7 +268,7 @@ def mock_db_result():
"title": "Test Transcript",
"created_at": datetime(2024, 6, 15, tzinfo=timezone.utc),
"duration": 3600.0,
- "status": "completed",
+ "status": "ended",
"user_id": "test-user",
"room_id": "room1",
"source_kind": SourceKind.LIVE,
@@ -433,7 +433,7 @@ class TestSearchResultModel:
room_id="room-456",
source_kind=SourceKind.ROOM,
created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
- status="completed",
+ status="ended",
rank=0.85,
duration=1800.5,
search_snippets=["snippet 1", "snippet 2"],
@@ -443,7 +443,7 @@ class TestSearchResultModel:
assert result.title == "Test Title"
assert result.user_id == "user-123"
assert result.room_id == "room-456"
- assert result.status == "completed"
+ assert result.status == "ended"
assert result.rank == 0.85
assert result.duration == 1800.5
assert len(result.search_snippets) == 2
@@ -474,7 +474,7 @@ class TestSearchResultModel:
id="test-id",
source_kind=SourceKind.LIVE,
created_at=datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc),
- status="completed",
+ status="ended",
rank=0.9,
duration=None,
search_snippets=[],
diff --git a/server/tests/test_search_long_summary.py b/server/tests/test_search_long_summary.py
index 8857778b..3f911a99 100644
--- a/server/tests/test_search_long_summary.py
+++ b/server/tests/test_search_long_summary.py
@@ -25,7 +25,7 @@ async def test_long_summary_snippet_prioritization():
"id": test_id,
"name": "Test Snippet Priority",
"title": "Meeting About Projects",
- "status": "completed",
+ "status": "ended",
"locked": False,
"duration": 1800.0,
"created_at": datetime.now(timezone.utc),
@@ -106,7 +106,7 @@ async def test_long_summary_only_search():
"id": test_id,
"name": "Test Long Only",
"title": "Standard Meeting",
- "status": "completed",
+ "status": "ended",
"locked": False,
"duration": 1800.0,
"created_at": datetime.now(timezone.utc),
diff --git a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx
index 0eebadc8..20164993 100644
--- a/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx
+++ b/www/app/(app)/browse/_components/TranscriptStatusIcon.tsx
@@ -7,9 +7,10 @@ import {
FaMicrophone,
FaGear,
} from "react-icons/fa6";
+import { TranscriptStatus } from "../../../lib/transcript";
interface TranscriptStatusIconProps {
- status: string;
+ status: TranscriptStatus;
}
export default function TranscriptStatusIcon({
diff --git a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx
index 1f5d1588..534f0c0a 100644
--- a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx
@@ -5,6 +5,7 @@ import useParticipants from "../../useParticipants";
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
import { featureEnabled } from "../../../../domainContext";
import { TopicItem } from "./TopicItem";
+import { TranscriptStatus } from "../../../../lib/transcript";
type TopicListProps = {
topics: Topic[];
@@ -14,7 +15,7 @@ type TopicListProps = {
];
autoscroll: boolean;
transcriptId: string;
- status: string;
+ status: TranscriptStatus | null;
currentTranscriptText: any;
};
diff --git a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
index c885ca6e..1c7705f4 100644
--- a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
@@ -9,8 +9,10 @@ import ParticipantList from "./participantList";
import type { components } from "../../../../reflector-api";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { SelectedText, selectedTextIsTimeSlice } from "./types";
-import { useTranscriptUpdate } from "../../../../lib/apiHooks";
-import useTranscript from "../../useTranscript";
+import {
+ useTranscriptGet,
+ useTranscriptUpdate,
+} from "../../../../lib/apiHooks";
import { useError } from "../../../../(errors)/errorContext";
import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react";
@@ -25,7 +27,7 @@ export default function TranscriptCorrect({
params: { transcriptId },
}: TranscriptCorrect) {
const updateTranscriptMutation = useTranscriptUpdate();
- const transcript = useTranscript(transcriptId);
+ const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState();
const [currentTopic, _sct] = stateCurrentTopic;
const stateSelectedText = useState();
@@ -36,7 +38,7 @@ export default function TranscriptCorrect({
const router = useRouter();
const markAsDone = async () => {
- if (transcript.response && !transcript.response.reviewed) {
+ if (transcript.data && !transcript.data.reviewed) {
try {
await updateTranscriptMutation.mutateAsync({
params: {
@@ -114,7 +116,7 @@ export default function TranscriptCorrect({
}}
/>
- {transcript.response && !transcript.response?.reviewed && (
+ {transcript.data && !transcript.data?.reviewed && (
;
}
@@ -86,7 +91,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic}
waveform={waveform.waveform}
media={mp3.media}
- mediaDuration={transcript.response?.duration || null}
+ mediaDuration={transcript.data?.duration || null}
/>
) : !mp3.loading && (waveform.error || mp3.error) ? (
@@ -116,10 +121,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
{
- transcript.reload();
+ transcript.refetch().then(() => {});
}}
/>
@@ -136,23 +141,23 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic}
autoscroll={false}
transcriptId={transcriptId}
- status={transcript.response?.status}
+ status={transcript.data?.status || null}
currentTranscriptText=""
/>
- {transcript.response && topics.topics ? (
+ {transcript.data && topics.topics ? (
<>
{
- transcript.reload();
+ onUpdate={() => {
+ transcript.refetch();
}}
/>
>
) : (
- {transcript.response.status == "processing" ? (
+ {transcript?.data?.status == "processing" ? (
Loading Transcript
) : (
diff --git a/www/app/(app)/transcripts/[transcriptId]/record/page.tsx b/www/app/(app)/transcripts/[transcriptId]/record/page.tsx
index 8f6634b0..0dc26c6d 100644
--- a/www/app/(app)/transcripts/[transcriptId]/record/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/record/page.tsx
@@ -2,7 +2,6 @@
import { useEffect, useState } from "react";
import Recorder from "../../recorder";
import { TopicList } from "../_components/TopicList";
-import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import { Topic } from "../../webSocketTypes";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
@@ -11,6 +10,8 @@ import useMp3 from "../../useMp3";
import WaveformLoading from "../../waveformLoading";
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
import LiveTrancription from "../../liveTranscription";
+import { useTranscriptGet } from "../../../../lib/apiHooks";
+import { TranscriptStatus } from "../../../../lib/transcript";
type TranscriptDetails = {
params: {
@@ -19,7 +20,7 @@ type TranscriptDetails = {
};
const TranscriptRecord = (details: TranscriptDetails) => {
- const transcript = useTranscript(details.params.transcriptId);
+ const transcript = useTranscriptGet(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState(null);
@@ -29,8 +30,8 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const router = useRouter();
- const [status, setStatus] = useState(
- webSockets.status.value || transcript.response?.status || "idle",
+ const [status, setStatus] = useState(
+ webSockets.status?.value || transcript.data?.status || "idle",
);
useEffect(() => {
@@ -41,7 +42,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
useEffect(() => {
//TODO HANDLE ERROR STATUS BETTER
const newStatus =
- webSockets.status.value || transcript.response?.status || "idle";
+ webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
@@ -49,7 +50,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl);
}
- }, [webSockets.status.value, transcript.response?.status]);
+ }, [webSockets.status?.value, transcript.data?.status]);
useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow();
diff --git a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
index 567272ff..844d05e9 100644
--- a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
@@ -1,12 +1,12 @@
"use client";
import { useEffect, useState } from "react";
-import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
import useMp3 from "../../useMp3";
import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react";
import FileUploadButton from "../../fileUploadButton";
+import { useTranscriptGet } from "../../../../lib/apiHooks";
type TranscriptUpload = {
params: {
@@ -15,7 +15,7 @@ type TranscriptUpload = {
};
const TranscriptUpload = (details: TranscriptUpload) => {
- const transcript = useTranscript(details.params.transcriptId);
+ const transcript = useTranscriptGet(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId);
@@ -25,13 +25,13 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const router = useRouter();
const [status_, setStatus] = useState(
- webSockets.status.value || transcript.response?.status || "idle",
+ webSockets.status?.value || transcript.data?.status || "idle",
);
// status is obviously done if we have transcript
const status =
- !transcript.loading && transcript.response?.status === "ended"
- ? transcript.response?.status
+ !transcript.isLoading && transcript.data?.status === "ended"
+ ? transcript.data?.status
: status_;
useEffect(() => {
@@ -43,9 +43,9 @@ const TranscriptUpload = (details: TranscriptUpload) => {
//TODO HANDLE ERROR STATUS BETTER
// TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib
const newStatus =
- transcript.response?.status === "ended"
+ transcript.data?.status === "ended"
? "ended"
- : webSockets.status.value || transcript.response?.status || "idle";
+ : webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
@@ -53,7 +53,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl);
}
- }, [webSockets.status.value, transcript.response?.status]);
+ }, [webSockets.status?.value, transcript.data?.status]);
useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow();
diff --git a/www/app/(app)/transcripts/recorder.tsx b/www/app/(app)/transcripts/recorder.tsx
index 2a81395a..1cf68c39 100644
--- a/www/app/(app)/transcripts/recorder.tsx
+++ b/www/app/(app)/transcripts/recorder.tsx
@@ -11,10 +11,11 @@ import useAudioDevice from "./useAudioDevice";
import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react";
import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu";
import { RECORD_A_MEETING_URL } from "../../api/urls";
+import { TranscriptStatus } from "../../lib/transcript";
type RecorderProps = {
transcriptId: string;
- status: string;
+ status: TranscriptStatus;
};
export default function Recorder(props: RecorderProps) {
diff --git a/www/app/(app)/transcripts/useTranscript.ts b/www/app/(app)/transcripts/useTranscript.ts
deleted file mode 100644
index 3e56fb9e..00000000
--- a/www/app/(app)/transcripts/useTranscript.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import type { components } from "../../reflector-api";
-import { useTranscriptGet } from "../../lib/apiHooks";
-
-type GetTranscript = components["schemas"]["GetTranscript"];
-
-type ErrorTranscript = {
- error: Error;
- loading: false;
- response: null;
- reload: () => void;
-};
-
-type LoadingTranscript = {
- response: null;
- loading: true;
- error: false;
- reload: () => void;
-};
-
-type SuccessTranscript = {
- response: GetTranscript;
- loading: false;
- error: null;
- reload: () => void;
-};
-
-const useTranscript = (
- id: string | null,
-): ErrorTranscript | LoadingTranscript | SuccessTranscript => {
- const { data, isLoading, error, refetch } = useTranscriptGet(id);
-
- // Map to the expected return format
- if (isLoading) {
- return {
- response: null,
- loading: true,
- error: false,
- reload: refetch,
- };
- }
-
- if (error) {
- return {
- error: error as Error,
- loading: false,
- response: null,
- reload: refetch,
- };
- }
-
- // Check if data is undefined or null
- if (!data) {
- return {
- response: null,
- loading: true,
- error: false,
- reload: refetch,
- };
- }
-
- return {
- response: data,
- loading: false,
- error: null,
- reload: refetch,
- };
-};
-
-export default useTranscript;
diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts
index 2b3205c4..f3b009c0 100644
--- a/www/app/(app)/transcripts/useWebSockets.ts
+++ b/www/app/(app)/transcripts/useWebSockets.ts
@@ -16,7 +16,7 @@ export type UseWebSockets = {
title: string;
topics: Topic[];
finalSummary: FinalSummary;
- status: Status;
+ status: Status | null;
waveform: AudioWaveform | null;
duration: number | null;
};
@@ -34,7 +34,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [finalSummary, setFinalSummary] = useState({
summary: "",
});
- const [status, setStatus] = useState({ value: "" });
+ const [status, setStatus] = useState(null);
const { setError } = useError();
const { websocket_url: websocketUrl } = useContext(DomainContext);
diff --git a/www/app/(app)/transcripts/webSocketTypes.ts b/www/app/(app)/transcripts/webSocketTypes.ts
index 4ec98946..5422cc24 100644
--- a/www/app/(app)/transcripts/webSocketTypes.ts
+++ b/www/app/(app)/transcripts/webSocketTypes.ts
@@ -1,4 +1,5 @@
import type { components } from "../../reflector-api";
+import type { TranscriptStatus } from "../../lib/transcript";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
@@ -13,7 +14,7 @@ export type FinalSummary = {
};
export type Status = {
- value: string;
+ value: TranscriptStatus;
};
export type TranslatedTopic = {
diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts
index 94d84c9b..3b5eed2b 100644
--- a/www/app/lib/apiHooks.ts
+++ b/www/app/lib/apiHooks.ts
@@ -96,8 +96,6 @@ export function useTranscriptProcess() {
}
export function useTranscriptGet(transcriptId: string | null) {
- const { isAuthenticated } = useAuthReady();
-
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}",
@@ -109,7 +107,7 @@ export function useTranscriptGet(transcriptId: string | null) {
},
},
{
- enabled: !!transcriptId && isAuthenticated,
+ enabled: !!transcriptId,
},
);
}
@@ -292,18 +290,16 @@ export function useTranscriptUploadAudio() {
}
export function useTranscriptWaveform(transcriptId: string | null) {
- const { isAuthenticated } = useAuthReady();
-
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/audio/waveform",
{
params: {
- path: { transcript_id: transcriptId || "" },
+ path: { transcript_id: transcriptId! },
},
},
{
- enabled: !!transcriptId && isAuthenticated,
+ enabled: !!transcriptId,
},
);
}
@@ -316,7 +312,7 @@ export function useTranscriptMP3(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/audio/mp3",
{
params: {
- path: { transcript_id: transcriptId || "" },
+ path: { transcript_id: transcriptId! },
},
},
{
@@ -326,8 +322,6 @@ export function useTranscriptMP3(transcriptId: string | null) {
}
export function useTranscriptTopics(transcriptId: string | null) {
- const { isAuthenticated } = useAuthReady();
-
return $api.useQuery(
"get",
"/v1/transcripts/{transcript_id}/topics",
@@ -337,7 +331,7 @@ export function useTranscriptTopics(transcriptId: string | null) {
},
},
{
- enabled: !!transcriptId && isAuthenticated,
+ enabled: !!transcriptId,
},
);
}
diff --git a/www/app/lib/transcript.ts b/www/app/lib/transcript.ts
new file mode 100644
index 00000000..d1fd8b3d
--- /dev/null
+++ b/www/app/lib/transcript.ts
@@ -0,0 +1,5 @@
+import { components } from "../reflector-api";
+
+type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"];
+
+export type TranscriptStatus = ApiTranscriptStatus;
diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts
index 8a2cadb0..2b92f4d4 100644
--- a/www/app/reflector-api.d.ts
+++ b/www/app/reflector-api.d.ts
@@ -926,8 +926,17 @@ export interface components {
source_kind: components["schemas"]["SourceKind"];
/** Created At */
created_at: string;
- /** Status */
- status: string;
+ /**
+ * Status
+ * @enum {string}
+ */
+ status:
+ | "idle"
+ | "uploaded"
+ | "recording"
+ | "processing"
+ | "error"
+ | "ended";
/** Rank */
rank: number;
/**
From cde99ca2716f84ba26798f289047732f0448742e Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Tue, 9 Sep 2025 15:48:07 -0400
Subject: [PATCH 21/77] fix: auth post (#624)
Co-authored-by: Igor Loskutov
---
server/reflector/db/meetings.py | 18 ------------------
www/app/lib/apiClient.tsx | 17 +++++------------
2 files changed, 5 insertions(+), 30 deletions(-)
diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py
index 85178351..bb7366b1 100644
--- a/server/reflector/db/meetings.py
+++ b/server/reflector/db/meetings.py
@@ -2,7 +2,6 @@ from datetime import datetime
from typing import Literal
import sqlalchemy as sa
-from fastapi import HTTPException
from pydantic import BaseModel, Field
from reflector.db import get_database, metadata
@@ -178,23 +177,6 @@ class MeetingController:
return None
return Meeting(**result)
- async def get_by_id_for_http(self, meeting_id: str, user_id: str | None) -> Meeting:
- """
- Get a meeting by ID for HTTP request.
-
- If not found, it will raise a 404 error.
- """
- query = meetings.select().where(meetings.c.id == meeting_id)
- result = await get_database().fetch_one(query)
- if not result:
- raise HTTPException(status_code=404, detail="Meeting not found")
-
- meeting = Meeting(**result)
- if result["user_id"] != user_id:
- meeting.host_room_url = ""
-
- return meeting
-
async def update_meeting(self, meeting_id: str, **kwargs):
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
await get_database().execute(query)
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
index cd97e151..133db7c3 100644
--- a/www/app/lib/apiClient.tsx
+++ b/www/app/lib/apiClient.tsx
@@ -2,12 +2,6 @@
import createClient from "openapi-fetch";
import type { paths } from "../reflector-api";
-import {
- queryOptions,
- useMutation,
- useQuery,
- useSuspenseQuery,
-} from "@tanstack/react-query";
import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
@@ -16,16 +10,11 @@ const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
: "http://localhost";
-// Create the base openapi-fetch client with a default URL
-// The actual URL will be set via middleware in AuthProvider
export const client = createClient({
baseUrl: API_URL,
});
-export const $api = createFetchClient(client);
-
-let currentAuthToken: string | null | undefined = null;
-
+// has to be called BEFORE $api is created with createFetchClient(client) or onRequest doesn't fire [at least for POST]
client.use({
onRequest({ request }) {
if (currentAuthToken) {
@@ -44,6 +33,10 @@ client.use({
},
});
+export const $api = createFetchClient(client);
+
+let currentAuthToken: string | null | undefined = null;
+
// the function contract: lightweight, idempotent
export const configureApiAuth = (token: string | null | undefined) => {
currentAuthToken = token;
From 3b85ff3bdf4fb053b103070646811bc990c0e70a Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Tue, 9 Sep 2025 16:27:46 -0400
Subject: [PATCH 22/77] fix: auth post (#626)
Co-authored-by: Igor Loskutov
---
www/app/lib/AuthProvider.tsx | 1 +
www/app/lib/apiClient.tsx | 26 ++++++++++++++++++++++----
2 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx
index 6c09926b..67c440da 100644
--- a/www/app/lib/AuthProvider.tsx
+++ b/www/app/lib/AuthProvider.tsx
@@ -88,6 +88,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
};
// not useEffect, we need it ASAP
+ // apparently, still no guarantee this code runs before mutations are fired
configureApiAuth(
contextValue.status === "authenticated" ? contextValue.accessToken : null,
);
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
index 133db7c3..4bedaebe 100644
--- a/www/app/lib/apiClient.tsx
+++ b/www/app/lib/apiClient.tsx
@@ -14,9 +14,27 @@ export const client = createClient({
baseUrl: API_URL,
});
-// has to be called BEFORE $api is created with createFetchClient(client) or onRequest doesn't fire [at least for POST]
+const waitForAuthTokenDefinitivePresenceOrAbscence = async () => {
+ let tries = 0;
+ let time = 0;
+ const STEP = 100;
+ while (currentAuthToken === undefined) {
+ await new Promise((resolve) => setTimeout(resolve, STEP));
+ time += STEP;
+ tries++;
+ // most likely first try is more than enough, if it's more there's already something weird happens
+ if (tries > 10) {
+ // even when there's no auth assumed at all, we probably should explicitly call configureApiAuth(null)
+ throw new Error(
+ `Could not get auth token definitive presence/absence in ${time}ms. not calling configureApiAuth?`,
+ );
+ }
+ }
+};
+
client.use({
- onRequest({ request }) {
+ async onRequest({ request }) {
+ await waitForAuthTokenDefinitivePresenceOrAbscence();
if (currentAuthToken) {
request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
}
@@ -35,9 +53,9 @@ client.use({
export const $api = createFetchClient(client);
-let currentAuthToken: string | null | undefined = null;
+let currentAuthToken: string | null | undefined = undefined;
// the function contract: lightweight, idempotent
-export const configureApiAuth = (token: string | null | undefined) => {
+export const configureApiAuth = (token: string | null) => {
currentAuthToken = token;
};
From 962038ee3f2a555dc3c03856be0e4409456e0996 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Tue, 9 Sep 2025 16:46:57 -0400
Subject: [PATCH 23/77] fix: auth post (#627)
Co-authored-by: Igor Loskutov
---
www/app/lib/AuthProvider.tsx | 6 +++++-
www/app/lib/apiClient.tsx | 4 +++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx
index 67c440da..1a8ebea6 100644
--- a/www/app/lib/AuthProvider.tsx
+++ b/www/app/lib/AuthProvider.tsx
@@ -90,7 +90,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
// not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired
configureApiAuth(
- contextValue.status === "authenticated" ? contextValue.accessToken : null,
+ contextValue.status === "authenticated"
+ ? contextValue.accessToken
+ : contextValue.status === "loading"
+ ? undefined
+ : null,
);
return (
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
index 4bedaebe..4b4ca6a0 100644
--- a/www/app/lib/apiClient.tsx
+++ b/www/app/lib/apiClient.tsx
@@ -56,6 +56,8 @@ export const $api = createFetchClient(client);
let currentAuthToken: string | null | undefined = undefined;
// the function contract: lightweight, idempotent
-export const configureApiAuth = (token: string | null) => {
+export const configureApiAuth = (token: string | null | undefined) => {
+ // watch only for the initial loading; "reloading" state assumes token presence/absence
+ if (token === undefined && currentAuthToken !== undefined) return;
currentAuthToken = token;
};
From fc363bd49b17b075e64f9186e5e0185abc325ea7 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Wed, 10 Sep 2025 08:15:47 -0600
Subject: [PATCH 24/77] fix: missing follow_redirects=True on modal endpoint
(#630)
---
server/reflector/processors/file_diarization_modal.py | 1 +
server/reflector/processors/file_transcript_modal.py | 1 +
2 files changed, 2 insertions(+)
diff --git a/server/reflector/processors/file_diarization_modal.py b/server/reflector/processors/file_diarization_modal.py
index 518f444e..8865063d 100644
--- a/server/reflector/processors/file_diarization_modal.py
+++ b/server/reflector/processors/file_diarization_modal.py
@@ -47,6 +47,7 @@ class FileDiarizationModalProcessor(FileDiarizationProcessor):
"audio_file_url": data.audio_url,
"timestamp": 0,
},
+ follow_redirects=True,
)
response.raise_for_status()
diarization_data = response.json()["diarization"]
diff --git a/server/reflector/processors/file_transcript_modal.py b/server/reflector/processors/file_transcript_modal.py
index b99cf806..82250b6c 100644
--- a/server/reflector/processors/file_transcript_modal.py
+++ b/server/reflector/processors/file_transcript_modal.py
@@ -54,6 +54,7 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
"language": data.language,
"batch": True,
},
+ follow_redirects=True,
)
response.raise_for_status()
result = response.json()
From 369ecdff13f3862d926a9c0b87df52c9d94c4dde Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Thu, 11 Sep 2025 11:20:41 -0400
Subject: [PATCH 25/77] feat: replace nextjs-config with environment variables
(#632)
* chore: remove nextjs-config
* build fix
* update readme
* explicit nextjs env vars + remove feature-unrelated things and obsolete vars from config
* full config removal
* remove force-dynamic from pages
* compile fix
* restore claude-deleted tests
* better .env.example
---------
Co-authored-by: Igor Loskutov
---
CLAUDE.md | 1 -
README.md | 36 +-
www/.env.example | 34 +
www/.gitignore | 1 -
www/app/(app)/layout.tsx | 10 +-
.../[transcriptId]/_components/TopicList.tsx | 3 +-
www/app/(app)/transcripts/new/page.tsx | 6 +-
www/app/(app)/transcripts/shareAndPrivacy.tsx | 3 +-
www/app/(app)/transcripts/shareLink.tsx | 3 +-
www/app/(app)/transcripts/shareZulip.tsx | 3 +-
www/app/(app)/transcripts/useMp3.ts | 9 +-
www/app/(app)/transcripts/useWebSockets.ts | 10 +-
www/app/domainContext.tsx | 49 -
www/app/layout.tsx | 22 +-
www/app/lib/apiClient.tsx | 6 +-
www/app/lib/auth.ts | 2 +
www/app/lib/edgeConfig.ts | 54 -
www/app/lib/features.ts | 55 +
www/app/lib/types.ts | 4 +
www/app/lib/utils.ts | 3 +-
www/app/providers.tsx | 10 +-
www/config-template.ts | 13 -
www/middleware.ts | 11 +-
www/package.json | 4 +-
www/pnpm-lock.yaml | 2562 ++++-------------
25 files changed, 755 insertions(+), 2159 deletions(-)
create mode 100644 www/.env.example
delete mode 100644 www/app/domainContext.tsx
delete mode 100644 www/app/lib/edgeConfig.ts
create mode 100644 www/app/lib/features.ts
delete mode 100644 www/config-template.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 14c58e42..22a99171 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -66,7 +66,6 @@ pnpm install
# Copy configuration templates
cp .env_template .env
-cp config-template.ts config.ts
```
**Development:**
diff --git a/README.md b/README.md
index 497dd5b5..ebb91fcb 100644
--- a/README.md
+++ b/README.md
@@ -99,11 +99,10 @@ Start with `cd www`.
```bash
pnpm install
-cp .env_template .env
-cp config-template.ts config.ts
+cp .env.example .env
```
-Then, fill in the environment variables in `.env` and the configuration in `config.ts` as needed. If you are unsure on how to proceed, ask in Zulip.
+Then, fill in the environment variables in `.env` as needed. If you are unsure on how to proceed, ask in Zulip.
**Run in development mode**
@@ -168,3 +167,34 @@ You can manually process an audio file by calling the process tool:
```bash
uv run python -m reflector.tools.process path/to/audio.wav
```
+
+
+## Feature Flags
+
+Reflector uses environment variable-based feature flags to control application functionality. These flags allow you to enable or disable features without code changes.
+
+### Available Feature Flags
+
+| Feature Flag | Environment Variable |
+|-------------|---------------------|
+| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` |
+| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` |
+| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` |
+| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` |
+| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` |
+
+### Setting Feature Flags
+
+Feature flags are controlled via environment variables using the pattern `NEXT_PUBLIC_FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
+
+**Examples:**
+```bash
+# Enable user authentication requirement
+NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
+
+# Disable browse functionality
+NEXT_PUBLIC_FEATURE_BROWSE=false
+
+# Enable Zulip integration
+NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
+```
diff --git a/www/.env.example b/www/.env.example
new file mode 100644
index 00000000..77017d91
--- /dev/null
+++ b/www/.env.example
@@ -0,0 +1,34 @@
+# Environment
+ENVIRONMENT=development
+NEXT_PUBLIC_ENV=development
+
+# Site Configuration
+NEXT_PUBLIC_SITE_URL=http://localhost:3000
+
+# Nextauth envs
+# not used in app code but in lib code
+NEXTAUTH_URL=http://localhost:3000
+NEXTAUTH_SECRET=your-nextauth-secret-here
+# / Nextauth envs
+
+# Authentication (Authentik OAuth/OIDC)
+AUTHENTIK_ISSUER=https://authentik.example.com/application/o/reflector
+AUTHENTIK_REFRESH_TOKEN_URL=https://authentik.example.com/application/o/token/
+AUTHENTIK_CLIENT_ID=your-client-id-here
+AUTHENTIK_CLIENT_SECRET=your-client-secret-here
+
+# Feature Flags
+# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
+# NEXT_PUBLIC_FEATURE_PRIVACY=false
+# NEXT_PUBLIC_FEATURE_BROWSE=true
+# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
+# NEXT_PUBLIC_FEATURE_ROOMS=true
+
+# API URLs
+NEXT_PUBLIC_API_URL=http://127.0.0.1:1250
+NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250
+NEXT_PUBLIC_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
diff --git a/www/.gitignore b/www/.gitignore
index c0ad8c1e..9acefbb2 100644
--- a/www/.gitignore
+++ b/www/.gitignore
@@ -40,7 +40,6 @@ next-env.d.ts
# Sentry Auth Token
.sentryclirc
-config.ts
# openapi logs
openapi-ts-error-*.log
diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx
index 801be28f..8bca1df6 100644
--- a/www/app/(app)/layout.tsx
+++ b/www/app/(app)/layout.tsx
@@ -1,5 +1,5 @@
import { Container, Flex, Link } from "@chakra-ui/react";
-import { getConfig } from "../lib/edgeConfig";
+import { featureEnabled } from "../lib/features";
import NextLink from "next/link";
import Image from "next/image";
import UserInfo from "../(auth)/userInfo";
@@ -11,8 +11,6 @@ export default async function AppLayout({
}: {
children: React.ReactNode;
}) {
- const config = await getConfig();
- const { requireLogin, privacy, browse, rooms } = config.features;
return (
Create
- {browse ? (
+ {featureEnabled("browse") ? (
<>
·
@@ -68,7 +66,7 @@ export default async function AppLayout({
) : (
<>>
)}
- {rooms ? (
+ {featureEnabled("rooms") ? (
<>
·
@@ -78,7 +76,7 @@ export default async function AppLayout({
) : (
<>>
)}
- {requireLogin ? (
+ {featureEnabled("requireLogin") ? (
<>
·
diff --git a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx
index 534f0c0a..fdf3db41 100644
--- a/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/_components/TopicList.tsx
@@ -3,10 +3,11 @@ import ScrollToBottom from "../../scrollToBottom";
import { Topic } from "../../webSocketTypes";
import useParticipants from "../../useParticipants";
import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
-import { featureEnabled } from "../../../../domainContext";
import { TopicItem } from "./TopicItem";
import { TranscriptStatus } from "../../../../lib/transcript";
+import { featureEnabled } from "../../../../lib/features";
+
type TopicListProps = {
topics: Topic[];
useActiveTopic: [
diff --git a/www/app/(app)/transcripts/new/page.tsx b/www/app/(app)/transcripts/new/page.tsx
index 0410bd97..8953e994 100644
--- a/www/app/(app)/transcripts/new/page.tsx
+++ b/www/app/(app)/transcripts/new/page.tsx
@@ -9,7 +9,6 @@ import { useRouter } from "next/navigation";
import useCreateTranscript from "../createTranscript";
import SelectSearch from "react-select-search";
import { supportedLanguages } from "../../../supportedLanguages";
-import { featureEnabled } from "../../../domainContext";
import {
Flex,
Box,
@@ -21,10 +20,9 @@ import {
Spacer,
} from "@chakra-ui/react";
import { useAuth } from "../../../lib/AuthProvider";
-import type { components } from "../../../reflector-api";
+import { featureEnabled } from "../../../lib/features";
const TranscriptCreate = () => {
- const isClient = typeof window !== "undefined";
const router = useRouter();
const auth = useAuth();
const isAuthenticated = auth.status === "authenticated";
@@ -176,7 +174,7 @@ const TranscriptCreate = () => {
placeholder="Choose your language"
/>
- {isClient && !loading ? (
+ {!loading ? (
permissionOk ? (
) : permissionDenied ? (
diff --git a/www/app/(app)/transcripts/shareAndPrivacy.tsx b/www/app/(app)/transcripts/shareAndPrivacy.tsx
index a53c93e3..8580015d 100644
--- a/www/app/(app)/transcripts/shareAndPrivacy.tsx
+++ b/www/app/(app)/transcripts/shareAndPrivacy.tsx
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
-import { featureEnabled } from "../../domainContext";
import { ShareMode, toShareMode } from "../../lib/shareMode";
import type { components } from "../../reflector-api";
@@ -24,6 +23,8 @@ import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip";
import { useAuth } from "../../lib/AuthProvider";
+import { featureEnabled } from "../../lib/features";
+
type ShareAndPrivacyProps = {
finalSummaryRef: any;
transcriptResponse: GetTranscript;
diff --git a/www/app/(app)/transcripts/shareLink.tsx b/www/app/(app)/transcripts/shareLink.tsx
index 7ea55f5e..ee7a01bf 100644
--- a/www/app/(app)/transcripts/shareLink.tsx
+++ b/www/app/(app)/transcripts/shareLink.tsx
@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect, use } from "react";
-import { featureEnabled } from "../../domainContext";
import { Button, Flex, Input, Text } from "@chakra-ui/react";
import QRCode from "react-qr-code";
+import { featureEnabled } from "../../lib/features";
+
type ShareLinkProps = {
transcriptId: string;
};
diff --git a/www/app/(app)/transcripts/shareZulip.tsx b/www/app/(app)/transcripts/shareZulip.tsx
index 62ce1b2c..5cee16c1 100644
--- a/www/app/(app)/transcripts/shareZulip.tsx
+++ b/www/app/(app)/transcripts/shareZulip.tsx
@@ -1,5 +1,4 @@
import { useState, useEffect, useMemo } from "react";
-import { featureEnabled } from "../../domainContext";
import type { components } from "../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"];
@@ -25,6 +24,8 @@ import {
useTranscriptPostToZulip,
} from "../../lib/apiHooks";
+import { featureEnabled } from "../../lib/features";
+
type ShareZulipProps = {
transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[];
diff --git a/www/app/(app)/transcripts/useMp3.ts b/www/app/(app)/transcripts/useMp3.ts
index 223a9a4a..cc0635ec 100644
--- a/www/app/(app)/transcripts/useMp3.ts
+++ b/www/app/(app)/transcripts/useMp3.ts
@@ -1,7 +1,7 @@
-import { useContext, useEffect, useState } from "react";
-import { DomainContext } from "../../domainContext";
+import { useEffect, useState } from "react";
import { useTranscriptGet } from "../../lib/apiHooks";
import { useAuth } from "../../lib/AuthProvider";
+import { API_URL } from "../../lib/apiClient";
export type Mp3Response = {
media: HTMLMediaElement | null;
@@ -19,7 +19,6 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
null,
);
const [audioDeleted, setAudioDeleted] = useState(null);
- const { api_url } = useContext(DomainContext);
const auth = useAuth();
const accessTokenInfo =
auth.status === "authenticated" ? auth.accessToken : null;
@@ -78,7 +77,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
// Audio is not deleted, proceed to load it
audioElement = document.createElement("audio");
- audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
+ audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto";
@@ -110,7 +109,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
if (handleError) audioElement.removeEventListener("error", handleError);
}
};
- }, [transcriptId, transcript, later, api_url]);
+ }, [transcriptId, transcript, later]);
const getNow = () => {
setLater(false);
diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts
index f3b009c0..09426061 100644
--- a/www/app/(app)/transcripts/useWebSockets.ts
+++ b/www/app/(app)/transcripts/useWebSockets.ts
@@ -1,13 +1,12 @@
-import { useContext, useEffect, useState } from "react";
+import { useEffect, useState } from "react";
import { Topic, FinalSummary, Status } from "./webSocketTypes";
import { useError } from "../../(errors)/errorContext";
-import { DomainContext } from "../../domainContext";
import type { components } from "../../reflector-api";
type AudioWaveform = components["schemas"]["AudioWaveform"];
type GetTranscriptSegmentTopic =
components["schemas"]["GetTranscriptSegmentTopic"];
import { useQueryClient } from "@tanstack/react-query";
-import { $api } from "../../lib/apiClient";
+import { $api, WEBSOCKET_URL } from "../../lib/apiClient";
export type UseWebSockets = {
transcriptTextLive: string;
@@ -37,7 +36,6 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [status, setStatus] = useState(null);
const { setError } = useError();
- const { websocket_url: websocketUrl } = useContext(DomainContext);
const queryClient = useQueryClient();
const [accumulatedText, setAccumulatedText] = useState("");
@@ -328,7 +326,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
if (!transcriptId) return;
- const url = `${websocketUrl}/v1/transcripts/${transcriptId}/events`;
+ const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
let ws = new WebSocket(url);
ws.onopen = () => {
@@ -494,7 +492,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
return () => {
ws.close();
};
- }, [transcriptId, websocketUrl]);
+ }, [transcriptId]);
return {
transcriptTextLive,
diff --git a/www/app/domainContext.tsx b/www/app/domainContext.tsx
deleted file mode 100644
index 7e415f1c..00000000
--- a/www/app/domainContext.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-"use client";
-import { createContext, useContext, useEffect, useState } from "react";
-import { DomainConfig } from "./lib/edgeConfig";
-
-type DomainContextType = Omit;
-
-export const DomainContext = createContext({
- features: {
- requireLogin: false,
- privacy: true,
- browse: false,
- sendToZulip: false,
- },
- api_url: "",
- websocket_url: "",
-});
-
-export const DomainContextProvider = ({
- config,
- children,
-}: {
- config: DomainConfig;
- children: any;
-}) => {
- const [context, setContext] = useState();
-
- useEffect(() => {
- if (!config) return;
- const { auth_callback_url, ...others } = config;
- setContext(others);
- }, [config]);
-
- if (!context) return;
-
- return (
- {children}
- );
-};
-
-// Get feature config client-side with
-export const featureEnabled = (
- featureName: "requireLogin" | "privacy" | "browse" | "sendToZulip",
-) => {
- const context = useContext(DomainContext);
-
- return context.features[featureName] as boolean | undefined;
-};
-
-// Get config server-side (out of react) : see lib/edgeConfig.
diff --git a/www/app/layout.tsx b/www/app/layout.tsx
index 62175be9..93fb15e9 100644
--- a/www/app/layout.tsx
+++ b/www/app/layout.tsx
@@ -3,9 +3,7 @@ import { Metadata, Viewport } from "next";
import { Poppins } from "next/font/google";
import { ErrorProvider } from "./(errors)/errorContext";
import ErrorMessage from "./(errors)/errorMessage";
-import { DomainContextProvider } from "./domainContext";
import { RecordingConsentProvider } from "./recordingConsentContext";
-import { getConfig } from "./lib/edgeConfig";
import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers";
@@ -68,21 +66,17 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
- const config = await getConfig();
-
return (
-
-
- "something went really wrong"}>
-
-
- {children}
-
-
-
-
+
+ "something went really wrong"}>
+
+
+ {children}
+
+
+
);
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
index 4b4ca6a0..95051913 100644
--- a/www/app/lib/apiClient.tsx
+++ b/www/app/lib/apiClient.tsx
@@ -6,10 +6,14 @@ import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
-const API_URL = !isBuildPhase
+export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
: "http://localhost";
+// TODO decide strict validation or not
+export const WEBSOCKET_URL =
+ process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
+
export const client = createClient({
baseUrl: API_URL,
});
diff --git a/www/app/lib/auth.ts b/www/app/lib/auth.ts
index c83db264..e562eaed 100644
--- a/www/app/lib/auth.ts
+++ b/www/app/lib/auth.ts
@@ -1,3 +1,5 @@
+import { assertExistsAndNonEmptyString } from "./utils";
+
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
diff --git a/www/app/lib/edgeConfig.ts b/www/app/lib/edgeConfig.ts
deleted file mode 100644
index f234a2cf..00000000
--- a/www/app/lib/edgeConfig.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { get } from "@vercel/edge-config";
-import { isBuildPhase } from "./next";
-
-type EdgeConfig = {
- [domainWithDash: string]: {
- features: {
- [featureName in
- | "requireLogin"
- | "privacy"
- | "browse"
- | "sendToZulip"]: boolean;
- };
- auth_callback_url: string;
- websocket_url: string;
- api_url: string;
- };
-};
-
-export type DomainConfig = EdgeConfig["domainWithDash"];
-
-// Edge config main keys can only be alphanumeric and _ or -
-export function edgeKeyToDomain(key: string) {
- return key.replaceAll("_", ".");
-}
-
-export function edgeDomainToKey(domain: string) {
- return domain.replaceAll(".", "_");
-}
-
-// get edge config server-side (prefer DomainContext when available), domain is the hostname
-export async function getConfig() {
- if (process.env.NEXT_PUBLIC_ENV === "development") {
- try {
- return require("../../config").localConfig;
- } catch (e) {
- // next build() WILL try to execute the require above even if conditionally protected
- // but thank god it at least runs catch{} block properly
- if (!isBuildPhase) throw new Error(e);
- return require("../../config-template").localConfig;
- }
- }
-
- const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
- let config = await get(edgeDomainToKey(domain));
-
- if (typeof config !== "object") {
- console.warn("No config for this domain, falling back to default");
- config = await get(edgeDomainToKey("default"));
- }
-
- if (typeof config !== "object") throw Error("Error fetching config");
-
- return config as DomainConfig;
-}
diff --git a/www/app/lib/features.ts b/www/app/lib/features.ts
new file mode 100644
index 00000000..86452ae7
--- /dev/null
+++ b/www/app/lib/features.ts
@@ -0,0 +1,55 @@
+export const FEATURES = [
+ "requireLogin",
+ "privacy",
+ "browse",
+ "sendToZulip",
+ "rooms",
+] as const;
+
+export type FeatureName = (typeof FEATURES)[number];
+
+export type Features = Readonly>;
+
+export const DEFAULT_FEATURES: Features = {
+ requireLogin: false,
+ privacy: true,
+ browse: false,
+ sendToZulip: false,
+ rooms: false,
+} as const;
+
+function parseBooleanEnv(
+ value: string | undefined,
+ defaultValue: boolean = false,
+): boolean {
+ if (!value) return defaultValue;
+ return value.toLowerCase() === "true";
+}
+
+// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
+const features: Features = {
+ requireLogin: parseBooleanEnv(
+ process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
+ DEFAULT_FEATURES.requireLogin,
+ ),
+ privacy: parseBooleanEnv(
+ process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
+ DEFAULT_FEATURES.privacy,
+ ),
+ browse: parseBooleanEnv(
+ process.env.NEXT_PUBLIC_FEATURE_BROWSE,
+ DEFAULT_FEATURES.browse,
+ ),
+ sendToZulip: parseBooleanEnv(
+ process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
+ DEFAULT_FEATURES.sendToZulip,
+ ),
+ rooms: parseBooleanEnv(
+ process.env.NEXT_PUBLIC_FEATURE_ROOMS,
+ DEFAULT_FEATURES.rooms,
+ ),
+};
+
+export const featureEnabled = (featureName: FeatureName): boolean => {
+ return features[featureName];
+};
diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts
index 0576e186..af5625ec 100644
--- a/www/app/lib/types.ts
+++ b/www/app/lib/types.ts
@@ -72,3 +72,7 @@ export const assertCustomSession = (s: S): CustomSession => {
// no other checks for now
return r as CustomSession;
};
+
+export type Mutable = {
+ -readonly [P in keyof T]: T[P];
+};
diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts
index 8e8651ff..11939cdb 100644
--- a/www/app/lib/utils.ts
+++ b/www/app/lib/utils.ts
@@ -171,5 +171,6 @@ export const assertNotExists = (
export const assertExistsAndNonEmptyString = (
value: string | null | undefined,
+ err?: string,
): NonEmptyString =>
- parseNonEmptyString(assertExists(value, "Expected non-empty string"));
+ parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
diff --git a/www/app/providers.tsx b/www/app/providers.tsx
index 2e3b78eb..020112ac 100644
--- a/www/app/providers.tsx
+++ b/www/app/providers.tsx
@@ -2,8 +2,8 @@
import { ChakraProvider } from "@chakra-ui/react";
import system from "./styles/theme";
+import dynamic from "next/dynamic";
-import { WherebyProvider } from "@whereby.com/browser-sdk/react";
import { Toaster } from "./components/ui/toaster";
import { NuqsAdapter } from "nuqs/adapters/next/app";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -11,6 +11,14 @@ import { queryClient } from "./lib/queryClient";
import { AuthProvider } from "./lib/AuthProvider";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
+const WherebyProvider = dynamic(
+ () =>
+ import("@whereby.com/browser-sdk/react").then((mod) => ({
+ default: mod.WherebyProvider,
+ })),
+ { ssr: false },
+);
+
export function Providers({ children }: { children: React.ReactNode }) {
return (
diff --git a/www/config-template.ts b/www/config-template.ts
deleted file mode 100644
index e8d4c01c..00000000
--- a/www/config-template.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-export const localConfig = {
- features: {
- requireLogin: true,
- privacy: true,
- browse: true,
- sendToZulip: true,
- rooms: true,
- },
- api_url: "http://127.0.0.1:1250",
- websocket_url: "ws://127.0.0.1:1250",
- auth_callback_url: "http://localhost:3000/auth-callback",
- zulip_streams: "", // Find the value on zulip
-};
diff --git a/www/middleware.ts b/www/middleware.ts
index 2b60d715..7f487cd2 100644
--- a/www/middleware.ts
+++ b/www/middleware.ts
@@ -1,5 +1,5 @@
import { withAuth } from "next-auth/middleware";
-import { getConfig } from "./app/lib/edgeConfig";
+import { featureEnabled } from "./app/lib/features";
import { NextResponse } from "next/server";
import { PROTECTED_PAGES } from "./app/lib/auth";
@@ -19,13 +19,12 @@ export const config = {
export default withAuth(
async function middleware(request) {
- const config = await getConfig();
const pathname = request.nextUrl.pathname;
// feature-flags protected paths
if (
- (!config.features.browse && pathname.startsWith("/browse")) ||
- (!config.features.rooms && pathname.startsWith("/rooms"))
+ (!featureEnabled("browse") && pathname.startsWith("/browse")) ||
+ (!featureEnabled("rooms") && pathname.startsWith("/rooms"))
) {
return NextResponse.redirect(request.nextUrl.origin);
}
@@ -33,10 +32,8 @@ export default withAuth(
{
callbacks: {
async authorized({ req, token }) {
- const config = await getConfig();
-
if (
- config.features.requireLogin &&
+ featureEnabled("requireLogin") &&
PROTECTED_PAGES.test(req.nextUrl.pathname)
) {
return !!token;
diff --git a/www/package.json b/www/package.json
index 27e30a5f..e55be4f0 100644
--- a/www/package.json
+++ b/www/package.json
@@ -20,7 +20,6 @@
"@sentry/nextjs": "^7.77.0",
"@tanstack/react-query": "^5.85.9",
"@types/ioredis": "^5.0.0",
- "@vercel/edge-config": "^0.4.1",
"@whereby.com/browser-sdk": "^3.3.4",
"autoprefixer": "10.4.20",
"axios": "^1.8.2",
@@ -63,8 +62,7 @@
"jest": "^30.1.3",
"openapi-typescript": "^7.9.1",
"prettier": "^3.0.0",
- "ts-jest": "^29.4.1",
- "vercel": "^37.3.0"
+ "ts-jest": "^29.4.1"
},
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
}
diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml
index f4346855..cf9351d4 100644
--- a/www/pnpm-lock.yaml
+++ b/www/pnpm-lock.yaml
@@ -24,16 +24,13 @@ importers:
version: 0.2.3(@fortawesome/fontawesome-svg-core@6.7.2)(react@18.3.1)
"@sentry/nextjs":
specifier: ^7.77.0
- version: 7.120.4(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
+ version: 7.77.0(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)
"@tanstack/react-query":
specifier: ^5.85.9
version: 5.85.9(react@18.3.1)
"@types/ioredis":
specifier: ^5.0.0
version: 5.0.0
- "@vercel/edge-config":
- specifier: ^0.4.1
- version: 0.4.1
"@whereby.com/browser-sdk":
specifier: ^3.3.4
version: 3.13.1(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -63,16 +60,16 @@ importers:
version: 0.525.0(react@18.3.1)
next:
specifier: ^14.2.30
- version: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ version: 14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
next-auth:
specifier: ^4.24.7
- version: 4.24.11(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 4.24.11(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nuqs:
specifier: ^2.4.3
- version: 2.4.3(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
+ version: 2.4.3(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
openapi-fetch:
specifier: ^0.14.0
version: 0.14.0
@@ -117,7 +114,7 @@ importers:
version: 9.11.1
tailwindcss:
specifier: ^3.3.2
- version: 3.4.17(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ version: 3.4.17(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
typescript:
specifier: ^5.1.6
version: 5.9.2
@@ -136,7 +133,7 @@ importers:
version: 18.2.20
jest:
specifier: ^30.1.3
- version: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ version: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
openapi-typescript:
specifier: ^7.9.1
version: 7.9.1(typescript@5.9.2)
@@ -145,10 +142,7 @@ importers:
version: 3.6.2
ts-jest:
specifier: ^29.4.1
- version: 29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)))(typescript@5.9.2)
- vercel:
- specifier: ^37.3.0
- version: 37.14.0
+ version: 29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)))(typescript@5.9.2)
packages:
"@alloc/quick-lru@5.2.0":
@@ -490,41 +484,6 @@ packages:
}
engines: { node: ">=12" }
- "@edge-runtime/format@2.2.1":
- resolution:
- {
- integrity: sha512-JQTRVuiusQLNNLe2W9tnzBlV/GvSVcozLl4XZHk5swnRZ/v6jp8TqR8P7sqmJsQqblDZ3EztcWmLDbhRje/+8g==,
- }
- engines: { node: ">=16" }
-
- "@edge-runtime/node-utils@2.3.0":
- resolution:
- {
- integrity: sha512-uUtx8BFoO1hNxtHjp3eqVPC/mWImGb2exOfGjMLUoipuWgjej+f4o/VP4bUI8U40gu7Teogd5VTeZUkGvJSPOQ==,
- }
- engines: { node: ">=16" }
-
- "@edge-runtime/ponyfill@2.4.2":
- resolution:
- {
- integrity: sha512-oN17GjFr69chu6sDLvXxdhg0Qe8EZviGSuqzR9qOiKh4MhFYGdBBcqRNzdmYeAdeRzOW2mM9yil4RftUQ7sUOA==,
- }
- engines: { node: ">=16" }
-
- "@edge-runtime/primitives@4.1.0":
- resolution:
- {
- integrity: sha512-Vw0lbJ2lvRUqc7/soqygUX216Xb8T3WBZ987oywz6aJqRxcwSVWwr9e+Nqo2m9bxobA9mdbWNNoRY6S9eko1EQ==,
- }
- engines: { node: ">=16" }
-
- "@edge-runtime/vm@3.2.0":
- resolution:
- {
- integrity: sha512-0dEVyRLM/lG4gp1R/Ik5bfPl/1wX00xFwd5KcNH602tzBa09oF7pbTKETEhR1GjZ75K6OJnYFu8II2dyMhONMw==,
- }
- engines: { node: ">=16" }
-
"@emnapi/core@1.4.5":
resolution:
{
@@ -688,13 +647,6 @@ packages:
}
engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 }
- "@fastify/busboy@2.1.1":
- resolution:
- {
- integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==,
- }
- engines: { node: ">=14" }
-
"@floating-ui/core@1.7.3":
resolution:
{
@@ -995,6 +947,12 @@ packages:
}
engines: { node: ">=6.0.0" }
+ "@jridgewell/source-map@0.3.11":
+ resolution:
+ {
+ integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==,
+ }
+
"@jridgewell/sourcemap-codec@1.5.5":
resolution:
{
@@ -1013,13 +971,6 @@ packages:
integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==,
}
- "@mapbox/node-pre-gyp@1.0.11":
- resolution:
- {
- integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==,
- }
- hasBin: true
-
"@napi-rs/wasm-runtime@0.2.12":
resolution:
{
@@ -1147,6 +1098,13 @@ packages:
}
engines: { node: ">=12.4.0" }
+ "@opentelemetry/api@1.9.0":
+ resolution:
+ {
+ integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==,
+ }
+ engines: { node: ">=8.0.0" }
+
"@pandacss/is-valid-prop@0.54.0":
resolution:
{
@@ -1626,13 +1584,6 @@ packages:
rollup:
optional: true
- "@rollup/pluginutils@4.2.1":
- resolution:
- {
- integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==,
- }
- engines: { node: ">= 8.0.0" }
-
"@rollup/pluginutils@5.2.0":
resolution:
{
@@ -1657,31 +1608,17 @@ packages:
integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==,
}
- "@sentry-internal/feedback@7.120.4":
+ "@sentry-internal/tracing@7.77.0":
resolution:
{
- integrity: sha512-eSwgvTdrh03zYYaI6UVOjI9p4VmKg6+c2+CBQfRZX++6wwnCVsNv7XF7WUIpVGBAkJ0N2oapjQmCzJKGKBRWQg==,
- }
- engines: { node: ">=12" }
-
- "@sentry-internal/replay-canvas@7.120.4":
- resolution:
- {
- integrity: sha512-2+W4CgUL1VzrPjArbTid4WhKh7HH21vREVilZdvffQPVwOEpgNTPAb69loQuTlhJVveh9hWTj2nE5UXLbLP+AA==,
- }
- engines: { node: ">=12" }
-
- "@sentry-internal/tracing@7.120.4":
- resolution:
- {
- integrity: sha512-Fz5+4XCg3akeoFK+K7g+d7HqGMjmnLoY2eJlpONJmaeT9pXY7yfUyXKZMmMajdE2LxxKJgQ2YKvSCaGVamTjHw==,
+ integrity: sha512-8HRF1rdqWwtINqGEdx8Iqs9UOP/n8E0vXUu3Nmbqj4p5sQPA7vvCfq+4Y4rTqZFc7sNdFpDsRION5iQEh8zfZw==,
}
engines: { node: ">=8" }
- "@sentry/browser@7.120.4":
+ "@sentry/browser@7.77.0":
resolution:
{
- integrity: sha512-ymlNtIPG6HAKzM/JXpWVGCzCNufZNADfy+O/olZuVJW5Be1DtOFyRnBvz0LeKbmxJbXb2lX/XMhuen6PXPdoQw==,
+ integrity: sha512-nJ2KDZD90H8jcPx9BysQLiQW+w7k7kISCWeRjrEMJzjtge32dmHA8G4stlUTRIQugy5F+73cOayWShceFP7QJQ==,
}
engines: { node: ">=8" }
@@ -1693,24 +1630,24 @@ packages:
engines: { node: ">= 8" }
hasBin: true
- "@sentry/core@7.120.4":
+ "@sentry/core@7.77.0":
resolution:
{
- integrity: sha512-TXu3Q5kKiq8db9OXGkWyXUbIxMMuttB5vJ031yolOl5T/B69JRyAoKuojLBjRv1XX583gS1rSSoX8YXX7ATFGA==,
+ integrity: sha512-Tj8oTYFZ/ZD+xW8IGIsU6gcFXD/gfE+FUxUaeSosd9KHwBQNOLhZSsYo/tTVf/rnQI/dQnsd4onPZLiL+27aTg==,
}
engines: { node: ">=8" }
- "@sentry/integrations@7.120.4":
+ "@sentry/integrations@7.77.0":
resolution:
{
- integrity: sha512-kkBTLk053XlhDCg7OkBQTIMF4puqFibeRO3E3YiVc4PGLnocXMaVpOSCkMqAc1k1kZ09UgGi8DxfQhnFEjUkpA==,
+ integrity: sha512-P055qXgBHeZNKnnVEs5eZYLdy6P49Zr77A1aWJuNih/EenzMy922GOeGy2mF6XYrn1YJSjEwsNMNsQkcvMTK8Q==,
}
engines: { node: ">=8" }
- "@sentry/nextjs@7.120.4":
+ "@sentry/nextjs@7.77.0":
resolution:
{
- integrity: sha512-1wtyDP1uiVvYqaJyCgXfP69eqyDgJrd6lERAVd4WqXNVEIs4vBT8oxfPQz6gxG2SJJUiTyQRjubMxuEc7dPoGQ==,
+ integrity: sha512-8tYPBt5luFjrng1sAMJqNjM9sq80q0jbt6yariADU9hEr7Zk8YqFaOI2/Q6yn9dZ6XyytIRtLEo54kk2AO94xw==,
}
engines: { node: ">=8" }
peerDependencies:
@@ -1721,63 +1658,57 @@ packages:
webpack:
optional: true
- "@sentry/node@7.120.4":
+ "@sentry/node@7.77.0":
resolution:
{
- integrity: sha512-qq3wZAXXj2SRWhqErnGCSJKUhPSlZ+RGnCZjhfjHpP49KNpcd9YdPTIUsFMgeyjdh6Ew6aVCv23g1hTP0CHpYw==,
+ integrity: sha512-Ob5tgaJOj0OYMwnocc6G/CDLWC7hXfVvKX/ofkF98+BbN/tQa5poL+OwgFn9BA8ud8xKzyGPxGU6LdZ8Oh3z/g==,
}
engines: { node: ">=8" }
- "@sentry/react@7.120.4":
+ "@sentry/react@7.77.0":
resolution:
{
- integrity: sha512-Pj1MSezEncE+5riuwsk8peMncuz5HR72Yr1/RdZhMZvUxoxAR/tkwD3aPcK6ddQJTagd2TGwhdr9SHuDLtONew==,
+ integrity: sha512-Q+htKzib5em0MdaQZMmPomaswaU3xhcVqmLi2CxqQypSjbYgBPPd+DuhrXKoWYLDDkkbY2uyfe4Lp3yLRWeXYw==,
}
engines: { node: ">=8" }
peerDependencies:
react: 15.x || 16.x || 17.x || 18.x
- "@sentry/replay@7.120.4":
+ "@sentry/replay@7.77.0":
resolution:
{
- integrity: sha512-FW8sPenNFfnO/K7sncsSTX4rIVak9j7VUiLIagJrcqZIC7d1dInFNjy8CdVJUlyz3Y3TOgIl3L3+ZpjfyMnaZg==,
+ integrity: sha512-M9Ik2J5ekl+C1Och3wzLRZVaRGK33BlnBwfwf3qKjgLDwfKW+1YkwDfTHbc2b74RowkJbOVNcp4m8ptlehlSaQ==,
}
engines: { node: ">=12" }
- "@sentry/types@7.120.4":
+ "@sentry/types@7.77.0":
resolution:
{
- integrity: sha512-cUq2hSSe6/qrU6oZsEP4InMI5VVdD86aypE+ENrQ6eZEVLTCYm1w6XhW1NvIu3UuWh7gZec4a9J7AFpYxki88Q==,
+ integrity: sha512-nfb00XRJVi0QpDHg+JkqrmEBHsqBnxJu191Ded+Cs1OJ5oPXEW6F59LVcBScGvMqe+WEk1a73eH8XezwfgrTsA==,
}
engines: { node: ">=8" }
- "@sentry/utils@7.120.4":
+ "@sentry/utils@7.77.0":
resolution:
{
- integrity: sha512-zCKpyDIWKHwtervNK2ZlaK8mMV7gVUijAgFeJStH+CU/imcdquizV3pFLlSQYRswG+Lbyd6CT/LGRh3IbtkCFw==,
+ integrity: sha512-NmM2kDOqVchrey3N5WSzdQoCsyDkQkiRxExPaNI2oKQ/jMWHs9yt0tSy7otPBcXs0AP59ihl75Bvm1tDRcsp5g==,
}
engines: { node: ">=8" }
- "@sentry/vercel-edge@7.120.4":
+ "@sentry/vercel-edge@7.77.0":
resolution:
{
- integrity: sha512-wZMnF7Rt2IBfStQTVDhjShEtLcsH1WNc7YVgzoibuIeRDrEmyx/MFIsru2BkhWnz7m0TRnWXxA40cH+6VZsf5w==,
+ integrity: sha512-ffddPCgxVeAccPYuH5sooZeHBqDuJ9OIhIRYKoDi4TvmwAzWo58zzZWhRpkHqHgIQdQvhLVZ5F+FSQVWnYSOkw==,
}
engines: { node: ">=8" }
- "@sentry/webpack-plugin@1.21.0":
+ "@sentry/webpack-plugin@1.20.0":
resolution:
{
- integrity: sha512-x0PYIMWcsTauqxgl7vWUY6sANl+XGKtx7DCVnnY7aOIIlIna0jChTAPANTfA2QrK+VK+4I/4JxatCEZBnXh3Og==,
+ integrity: sha512-Ssj1mJVFsfU6vMCOM2d+h+KQR7QHSfeIP16t4l20Uq/neqWXZUQ2yvQfe4S3BjdbJXz/X4Rw8Hfy1Sd0ocunYw==,
}
engines: { node: ">= 8" }
- "@sinclair/typebox@0.25.24":
- resolution:
- {
- integrity: sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==,
- }
-
"@sinclair/typebox@0.27.8":
resolution:
{
@@ -1852,19 +1783,6 @@ packages:
peerDependencies:
react: ^18 || ^19
- "@tootallnate/once@2.0.0":
- resolution:
- {
- integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==,
- }
- engines: { node: ">= 10" }
-
- "@ts-morph/common@0.11.1":
- resolution:
- {
- integrity: sha512-7hWZS0NRpEsNV8vWJzg7FEz6V8MaLNeJOmwmghqUXTpzk16V1LLZhdo+4QvE/+zv4cVci0OviuJFnqhEfoV3+g==,
- }
-
"@tsconfig/node10@1.0.11":
resolution:
{
@@ -1925,6 +1843,18 @@ packages:
integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==,
}
+ "@types/eslint-scope@3.7.7":
+ resolution:
+ {
+ integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==,
+ }
+
+ "@types/eslint@9.6.1":
+ resolution:
+ {
+ integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==,
+ }
+
"@types/estree-jsx@1.0.5":
resolution:
{
@@ -2010,12 +1940,6 @@ packages:
integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==,
}
- "@types/node@16.18.11":
- resolution:
- {
- integrity: sha512-3oJbGBUWuS6ahSnEq1eN2XrCyf4YsWI8OyCvo7c64zQJNplk3mO84t53o8lfTk+2ji59g5ycfc6qQ3fdHliHuA==,
- }
-
"@types/node@24.2.1":
resolution:
{
@@ -2365,122 +2289,94 @@ packages:
cpu: [x64]
os: [win32]
- "@vercel/build-utils@8.4.12":
+ "@webassemblyjs/ast@1.14.1":
resolution:
{
- integrity: sha512-pIH0b965wJhd1otROVPndfZenPKFVoYSaRjtSKVOT/oNBT13ifq86UVjb5ZjoVfqUI2TtSTP+68kBqLPeoq30g==,
+ integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==,
}
- "@vercel/edge-config-fs@0.1.0":
+ "@webassemblyjs/floating-point-hex-parser@1.13.2":
resolution:
{
- integrity: sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg==,
+ integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==,
}
- "@vercel/edge-config@0.4.1":
+ "@webassemblyjs/helper-api-error@1.13.2":
resolution:
{
- integrity: sha512-4Mc3H7lE+x4RrL17nY8CWeEorvJHbkNbQTy9p8H1tO7y11WeKj5xeZSr07wNgfWInKXDUwj5FZ3qd/jIzjPxug==,
- }
- engines: { node: ">=14.6" }
-
- "@vercel/error-utils@2.0.2":
- resolution:
- {
- integrity: sha512-Sj0LFafGpYr6pfCqrQ82X6ukRl5qpmVrHM/191kNYFqkkB9YkjlMAj6QcEsvCG259x4QZ7Tya++0AB85NDPbKQ==,
+ integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==,
}
- "@vercel/fun@1.1.0":
+ "@webassemblyjs/helper-buffer@1.14.1":
resolution:
{
- integrity: sha512-SpuPAo+MlAYMtcMcC0plx7Tv4Mp7SQhJJj1iIENlOnABL24kxHpL09XLQMGzZIzIW7upR8c3edwgfpRtp+dhVw==,
- }
- engines: { node: ">= 10" }
-
- "@vercel/gatsby-plugin-vercel-analytics@1.0.11":
- resolution:
- {
- integrity: sha512-iTEA0vY6RBPuEzkwUTVzSHDATo1aF6bdLLspI68mQ/BTbi5UQEGjpjyzdKOVcSYApDtFU6M6vypZ1t4vIEnHvw==,
+ integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==,
}
- "@vercel/gatsby-plugin-vercel-builder@2.0.56":
+ "@webassemblyjs/helper-numbers@1.13.2":
resolution:
{
- integrity: sha512-SZM8k/YcOcfk2p1cSZOuSK37CDBJtF/WiEr8CemDI/MBbXM4aC2StfzDd0F0cK/2rExpSA9lTAE9ia3w+cDS9w==,
+ integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==,
}
- "@vercel/go@3.2.0":
+ "@webassemblyjs/helper-wasm-bytecode@1.13.2":
resolution:
{
- integrity: sha512-zUCBoh57x1OEtw+TKdRhSQciqERrpDxLlPeBOYawUCC5uKjsBjhdq0U21+NGz2LcRUaYyYYGMw6BzqVaig9u1g==,
+ integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==,
}
- "@vercel/hydrogen@1.0.9":
+ "@webassemblyjs/helper-wasm-section@1.14.1":
resolution:
{
- integrity: sha512-IPAVaALuGAzt2apvTtBs5tB+8zZRzn/yG3AGp8dFyCsw/v5YOuk0Q5s8Z3fayLvJbFpjrKtqRNDZzVJBBU3MrQ==,
+ integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==,
}
- "@vercel/next@4.3.18":
+ "@webassemblyjs/ieee754@1.13.2":
resolution:
{
- integrity: sha512-ih6++AA7/NCcLkMpdsDhr/folMlAKsU1sYUoyOjq4rYE9sSapELtgxls0CArv4ehE2Tt4YwoxBISnKPZKK5SSA==,
+ integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==,
}
- "@vercel/nft@0.27.3":
+ "@webassemblyjs/leb128@1.13.2":
resolution:
{
- integrity: sha512-oySTdDSzUAFDXpsSLk9Q943o+/Yu/+TCFxnehpFQEf/3khi2stMpTHPVNwFdvZq/Z4Ky93lE+MGHpXCRpMkSCA==,
- }
- engines: { node: ">=16" }
- hasBin: true
-
- "@vercel/node@3.2.24":
- resolution:
- {
- integrity: sha512-KEm50YBmcfRNOw5NfdcqMI4BkP4+5TD9kRwAByHHlIZXLj1NTTknvMF+69sHBYzwpK/SUZIkeo7jTrtcl4g+RQ==,
+ integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==,
}
- "@vercel/python@4.3.1":
+ "@webassemblyjs/utf8@1.13.2":
resolution:
{
- integrity: sha512-pWRApBwUsAQJS8oZ7eKMiaBGbYJO71qw2CZqDFvkTj34FNBZtNIUcWSmqGfJJY5m2pU/9wt8z1CnKIyT9dstog==,
+ integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==,
}
- "@vercel/redwood@2.1.8":
+ "@webassemblyjs/wasm-edit@1.14.1":
resolution:
{
- integrity: sha512-qBUBqIDxPEYnxRh3tsvTaPMtBkyK/D2tt9tBugNPe0OeYnMCMXVj9SJYbxiDI2GzAEFUZn4Poh63CZtXMDb9Tg==,
+ integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==,
}
- "@vercel/remix-builder@2.2.13":
+ "@webassemblyjs/wasm-gen@1.14.1":
resolution:
{
- integrity: sha512-TenVtvfERodSwUjm0rzjz3v00Drd0FUXLWnwdwnv7VLgqmX2FW/2+1byhmPhJicMp3Eybl52GvF2/KbBkNo95w==,
+ integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==,
}
- "@vercel/routing-utils@3.1.0":
+ "@webassemblyjs/wasm-opt@1.14.1":
resolution:
{
- integrity: sha512-Ci5xTjVTJY/JLZXpCXpLehMft97i9fH34nu9PGav6DtwkVUF6TOPX86U0W0niQjMZ5n6/ZP0BwcJK2LOozKaGw==,
+ integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==,
}
- "@vercel/ruby@2.1.0":
+ "@webassemblyjs/wasm-parser@1.14.1":
resolution:
{
- integrity: sha512-UZYwlSEEfVnfzTmgkD+kxex9/gkZGt7unOWNyWFN7V/ZnZSsGBUgv6hXLnwejdRi3EztgRQEBd1kUKlXdIeC0Q==,
+ integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==,
}
- "@vercel/static-build@2.5.34":
+ "@webassemblyjs/wast-printer@1.14.1":
resolution:
{
- integrity: sha512-4RL60ghhBufs/45j6J9zQzMpt8JmUhp/4+xE8RxO80n6qTlc/oERKrWxzeXLEGF32whSHsB+ROJt0Ytytoz2Tw==,
- }
-
- "@vercel/static-config@3.0.0":
- resolution:
- {
- integrity: sha512-2qtvcBJ1bGY0dYGYh3iM7yGKkk971FujLEDXzuW5wcZsPr1GSEjO/w2iSr3qve6nDDtBImsGoDEnus5FI4+fIw==,
+ integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==,
}
"@whereby.com/browser-sdk@3.13.1":
@@ -2505,6 +2401,18 @@ packages:
}
engines: { node: ">=16.0.0" }
+ "@xtuc/ieee754@1.2.0":
+ resolution:
+ {
+ integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==,
+ }
+
+ "@xtuc/long@4.2.2":
+ resolution:
+ {
+ integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==,
+ }
+
"@zag-js/accordion@1.21.0":
resolution:
{
@@ -2922,19 +2830,14 @@ packages:
integrity: sha512-yI/CZizbk387TdkDCy9Uc4l53uaeQuWAIJESrmAwwq6yMNbHZ2dm5+1NHdZr/guES5TgyJa/BYJsNJeCsCfesg==,
}
- abbrev@1.1.1:
+ acorn-import-phases@1.0.4:
resolution:
{
- integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==,
- }
-
- acorn-import-attributes@1.9.5:
- resolution:
- {
- integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==,
+ integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==,
}
+ engines: { node: ">=10.13.0" }
peerDependencies:
- acorn: ^8
+ acorn: ^8.14.0
acorn-jsx@5.3.2:
resolution:
@@ -2973,16 +2876,35 @@ packages:
}
engines: { node: ">= 14" }
+ ajv-formats@2.1.1:
+ resolution:
+ {
+ integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==,
+ }
+ peerDependencies:
+ ajv: ^8.0.0
+ peerDependenciesMeta:
+ ajv:
+ optional: true
+
+ ajv-keywords@5.1.0:
+ resolution:
+ {
+ integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==,
+ }
+ peerDependencies:
+ ajv: ^8.8.2
+
ajv@6.12.6:
resolution:
{
integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==,
}
- ajv@8.6.3:
+ ajv@8.17.1:
resolution:
{
- integrity: sha512-SMJOdDP6LqTkD0Uq8qLi+gMwSt0imXLSV080qFVwJCpH9U6Mb+SUGHAXM0KNbcBPguytWyvFxcHgMLe2D2XSpw==,
+ integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==,
}
ansi-colors@4.1.3:
@@ -3047,26 +2969,6 @@ packages:
}
engines: { node: ">= 8" }
- aproba@2.1.0:
- resolution:
- {
- integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==,
- }
-
- are-we-there-yet@2.0.0:
- resolution:
- {
- integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==,
- }
- engines: { node: ">=10" }
- deprecated: This package is no longer supported.
-
- arg@4.1.0:
- resolution:
- {
- integrity: sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==,
- }
-
arg@4.1.3:
resolution:
{
@@ -3174,32 +3076,6 @@ packages:
}
engines: { node: ">= 0.4" }
- async-listen@1.2.0:
- resolution:
- {
- integrity: sha512-CcEtRh/oc9Jc4uWeUwdpG/+Mb2YUHKmdaTf0gUr7Wa+bfp4xx70HOb3RuSTJMvqKNB1TkdTfjLdrcz2X4rkkZA==,
- }
-
- async-listen@3.0.0:
- resolution:
- {
- integrity: sha512-V+SsTpDqkrWTimiotsyl33ePSjA5/KrithwupuvJ6ztsqPvGv6ge4OredFhPffVXiLN/QUWvE0XcqJaYgt6fOg==,
- }
- engines: { node: ">= 14" }
-
- async-listen@3.0.1:
- resolution:
- {
- integrity: sha512-cWMaNwUJnf37C/S5TfCkk/15MwbPRwVYALA2jtjkbHjCmAPiDXyNJy2q3p1KAZzDLHAWyarUWSujUoHR4pEgrA==,
- }
- engines: { node: ">= 14" }
-
- async-sema@3.1.1:
- resolution:
- {
- integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==,
- }
-
asynckit@0.4.0:
resolution:
{
@@ -3328,12 +3204,6 @@ packages:
}
engines: { node: ">=8" }
- bindings@1.5.0:
- resolution:
- {
- integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==,
- }
-
brace-expansion@1.1.12:
resolution:
{
@@ -3382,12 +3252,6 @@ packages:
engines: { node: ">= 0.4.0" }
hasBin: true
- buffer-crc32@0.2.13:
- resolution:
- {
- integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==,
- }
-
buffer-from@1.1.2:
resolution:
{
@@ -3407,13 +3271,6 @@ packages:
}
engines: { node: ">=10.16.0" }
- bytes@3.1.0:
- resolution:
- {
- integrity: sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==,
- }
- engines: { node: ">= 0.8" }
-
call-bind-apply-helpers@1.0.2:
resolution:
{
@@ -3532,13 +3389,6 @@ packages:
integrity: sha512-LuLBA6r4aS/4B7pvOqmT4Bi+GKnNNC/V18K0zDTRFjAxNeUzGsr0wmsOfFhFH7fGjwdx6GX6wyIQBkUhFox2Pw==,
}
- chokidar@3.3.1:
- resolution:
- {
- integrity: sha512-4QYCEWOcK3OJrxwvyyAOxFuhpvOVCYkr33LPfFNBjAD/w3sEzWsp2BUOkI4l9bHvWioAd0rc6NlHUOEaWkTeqg==,
- }
- engines: { node: ">= 8.10.0" }
-
chokidar@3.6.0:
resolution:
{
@@ -3553,18 +3403,12 @@ packages:
}
engines: { node: ">= 14.16.0" }
- chownr@1.1.4:
+ chrome-trace-event@1.0.4:
resolution:
{
- integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==,
+ integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==,
}
-
- chownr@2.0.0:
- resolution:
- {
- integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==,
- }
- engines: { node: ">=10" }
+ engines: { node: ">=6.0" }
ci-info@3.9.0:
resolution:
@@ -3580,12 +3424,6 @@ packages:
}
engines: { node: ">=8" }
- cjs-module-lexer@1.2.3:
- resolution:
- {
- integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==,
- }
-
cjs-module-lexer@2.1.0:
resolution:
{
@@ -3632,12 +3470,6 @@ packages:
}
engines: { iojs: ">= 1.0.0", node: ">= 0.12.0" }
- code-block-writer@10.1.1:
- resolution:
- {
- integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==,
- }
-
collect-v8-coverage@1.0.2:
resolution:
{
@@ -3657,13 +3489,6 @@ packages:
integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==,
}
- color-support@1.1.3:
- resolution:
- {
- integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==,
- }
- hasBin: true
-
colorette@1.4.0:
resolution:
{
@@ -3683,6 +3508,12 @@ packages:
integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==,
}
+ commander@2.20.3:
+ resolution:
+ {
+ integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==,
+ }
+
commander@4.1.1:
resolution:
{
@@ -3702,26 +3533,6 @@ packages:
integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==,
}
- console-control-strings@1.1.0:
- resolution:
- {
- integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==,
- }
-
- content-type@1.0.4:
- resolution:
- {
- integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==,
- }
- engines: { node: ">= 0.6" }
-
- convert-hrtime@3.0.0:
- resolution:
- {
- integrity: sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==,
- }
- engines: { node: ">=8" }
-
convert-source-map@1.9.0:
resolution:
{
@@ -3813,18 +3624,6 @@ packages:
supports-color:
optional: true
- debug@4.1.1:
- resolution:
- {
- integrity: sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==,
- }
- deprecated: Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)
- peerDependencies:
- supports-color: "*"
- peerDependenciesMeta:
- supports-color:
- optional: true
-
debug@4.3.7:
resolution:
{
@@ -3900,12 +3699,6 @@ packages:
}
engines: { node: ">=0.4.0" }
- delegates@1.0.0:
- resolution:
- {
- integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==,
- }
-
denque@2.1.0:
resolution:
{
@@ -3913,13 +3706,6 @@ packages:
}
engines: { node: ">=0.10" }
- depd@1.1.2:
- resolution:
- {
- integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==,
- }
- engines: { node: ">= 0.6" }
-
dequal@2.0.3:
resolution:
{
@@ -3941,13 +3727,6 @@ packages:
engines: { node: ">=0.10" }
hasBin: true
- detect-libc@2.0.4:
- resolution:
- {
- integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==,
- }
- engines: { node: ">=8" }
-
detect-newline@3.1.0:
resolution:
{
@@ -4024,14 +3803,6 @@ packages:
integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==,
}
- edge-runtime@2.5.9:
- resolution:
- {
- integrity: sha512-pk+k0oK0PVXdlT4oRp4lwh+unuKB7Ng4iZ2HB+EZ7QCEQizX360Rp/F4aRpgpRgdP2ufB35N+1KppHmYjqIGSg==,
- }
- engines: { node: ">=16" }
- hasBin: true
-
electron-to-chromium@1.5.200:
resolution:
{
@@ -4057,18 +3828,6 @@ packages:
integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==,
}
- end-of-stream@1.1.0:
- resolution:
- {
- integrity: sha512-EoulkdKF/1xa92q25PbjuDcgJ9RDHYU2Rs3SCIvs2/dSQ3BpmxneNHmA/M7fe60M3PrV7nNGTTNbkK62l6vXiQ==,
- }
-
- end-of-stream@1.4.5:
- resolution:
- {
- integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==,
- }
-
engine.io-client@6.5.4:
resolution:
{
@@ -4082,6 +3841,13 @@ packages:
}
engines: { node: ">=10.0.0" }
+ enhanced-resolve@5.18.3:
+ resolution:
+ {
+ integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==,
+ }
+ engines: { node: ">=10.13.0" }
+
err-code@3.0.1:
resolution:
{
@@ -4122,10 +3888,10 @@ packages:
}
engines: { node: ">= 0.4" }
- es-module-lexer@1.4.1:
+ es-module-lexer@1.7.0:
resolution:
{
- integrity: sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==,
+ integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==,
}
es-object-atoms@1.1.1:
@@ -4156,194 +3922,6 @@ packages:
}
engines: { node: ">= 0.4" }
- esbuild-android-64@0.14.47:
- resolution:
- {
- integrity: sha512-R13Bd9+tqLVFndncMHssZrPWe6/0Kpv2/dt4aA69soX4PRxlzsVpCvoJeFE8sOEoeVEiBkI0myjlkDodXlHa0g==,
- }
- engines: { node: ">=12" }
- cpu: [x64]
- os: [android]
-
- esbuild-android-arm64@0.14.47:
- resolution:
- {
- integrity: sha512-OkwOjj7ts4lBp/TL6hdd8HftIzOy/pdtbrNA4+0oVWgGG64HrdVzAF5gxtJufAPOsEjkyh1oIYvKAUinKKQRSQ==,
- }
- engines: { node: ">=12" }
- cpu: [arm64]
- os: [android]
-
- esbuild-darwin-64@0.14.47:
- resolution:
- {
- integrity: sha512-R6oaW0y5/u6Eccti/TS6c/2c1xYTb1izwK3gajJwi4vIfNs1s8B1dQzI1UiC9T61YovOQVuePDcfqHLT3mUZJA==,
- }
- engines: { node: ">=12" }
- cpu: [x64]
- os: [darwin]
-
- esbuild-darwin-arm64@0.14.47:
- resolution:
- {
- integrity: sha512-seCmearlQyvdvM/noz1L9+qblC5vcBrhUaOoLEDDoLInF/VQ9IkobGiLlyTPYP5dW1YD4LXhtBgOyevoIHGGnw==,
- }
- engines: { node: ">=12" }
- cpu: [arm64]
- os: [darwin]
-
- esbuild-freebsd-64@0.14.47:
- resolution:
- {
- integrity: sha512-ZH8K2Q8/Ux5kXXvQMDsJcxvkIwut69KVrYQhza/ptkW50DC089bCVrJZZ3sKzIoOx+YPTrmsZvqeZERjyYrlvQ==,
- }
- engines: { node: ">=12" }
- cpu: [x64]
- os: [freebsd]
-
- esbuild-freebsd-arm64@0.14.47:
- resolution:
- {
- integrity: sha512-ZJMQAJQsIOhn3XTm7MPQfCzEu5b9STNC+s90zMWe2afy9EwnHV7Ov7ohEMv2lyWlc2pjqLW8QJnz2r0KZmeAEQ==,
- }
- engines: { node: ">=12" }
- cpu: [arm64]
- os: [freebsd]
-
- esbuild-linux-32@0.14.47:
- resolution:
- {
- integrity: sha512-FxZOCKoEDPRYvq300lsWCTv1kcHgiiZfNrPtEhFAiqD7QZaXrad8LxyJ8fXGcWzIFzRiYZVtB3ttvITBvAFhKw==,
- }
- engines: { node: ">=12" }
- cpu: [ia32]
- os: [linux]
-
- esbuild-linux-64@0.14.47:
- resolution:
- {
- integrity: sha512-nFNOk9vWVfvWYF9YNYksZptgQAdstnDCMtR6m42l5Wfugbzu11VpMCY9XrD4yFxvPo9zmzcoUL/88y0lfJZJJw==,
- }
- engines: { node: ">=12" }
- cpu: [x64]
- os: [linux]
-
- esbuild-linux-arm64@0.14.47:
- resolution:
- {
- integrity: sha512-ywfme6HVrhWcevzmsufjd4iT3PxTfCX9HOdxA7Hd+/ZM23Y9nXeb+vG6AyA6jgq/JovkcqRHcL9XwRNpWG6XRw==,
- }
- engines: { node: ">=12" }
- cpu: [arm64]
- os: [linux]
-
- esbuild-linux-arm@0.14.47:
- resolution:
- {
- integrity: sha512-ZGE1Bqg/gPRXrBpgpvH81tQHpiaGxa8c9Rx/XOylkIl2ypLuOcawXEAo8ls+5DFCcRGt/o3sV+PzpAFZobOsmA==,
- }
- engines: { node: ">=12" }
- cpu: [arm]
- os: [linux]
-
- esbuild-linux-mips64le@0.14.47:
- resolution:
- {
- integrity: sha512-mg3D8YndZ1LvUiEdDYR3OsmeyAew4MA/dvaEJxvyygahWmpv1SlEEnhEZlhPokjsUMfRagzsEF/d/2XF+kTQGg==,
- }
- engines: { node: ">=12" }
- cpu: [mips64el]
- os: [linux]
-
- esbuild-linux-ppc64le@0.14.47:
- resolution:
- {
- integrity: sha512-WER+f3+szmnZiWoK6AsrTKGoJoErG2LlauSmk73LEZFQ/iWC+KhhDsOkn1xBUpzXWsxN9THmQFltLoaFEH8F8w==,
- }
- engines: { node: ">=12" }
- cpu: [ppc64]
- os: [linux]
-
- esbuild-linux-riscv64@0.14.47:
- resolution:
- {
- integrity: sha512-1fI6bP3A3rvI9BsaaXbMoaOjLE3lVkJtLxsgLHqlBhLlBVY7UqffWBvkrX/9zfPhhVMd9ZRFiaqXnB1T7BsL2g==,
- }
- engines: { node: ">=12" }
- cpu: [riscv64]
- os: [linux]
-
- esbuild-linux-s390x@0.14.47:
- resolution:
- {
- integrity: sha512-eZrWzy0xFAhki1CWRGnhsHVz7IlSKX6yT2tj2Eg8lhAwlRE5E96Hsb0M1mPSE1dHGpt1QVwwVivXIAacF/G6mw==,
- }
- engines: { node: ">=12" }
- cpu: [s390x]
- os: [linux]
-
- esbuild-netbsd-64@0.14.47:
- resolution:
- {
- integrity: sha512-Qjdjr+KQQVH5Q2Q1r6HBYswFTToPpss3gqCiSw2Fpq/ua8+eXSQyAMG+UvULPqXceOwpnPo4smyZyHdlkcPppQ==,
- }
- engines: { node: ">=12" }
- cpu: [x64]
- os: [netbsd]
-
- esbuild-openbsd-64@0.14.47:
- resolution:
- {
- integrity: sha512-QpgN8ofL7B9z8g5zZqJE+eFvD1LehRlxr25PBkjyyasakm4599iroUpaj96rdqRlO2ShuyqwJdr+oNqWwTUmQw==,
- }
- engines: { node: ">=12" }
- cpu: [x64]
- os: [openbsd]
-
- esbuild-sunos-64@0.14.47:
- resolution:
- {
- integrity: sha512-uOeSgLUwukLioAJOiGYm3kNl+1wJjgJA8R671GYgcPgCx7QR73zfvYqXFFcIO93/nBdIbt5hd8RItqbbf3HtAQ==,
- }
- engines: { node: ">=12" }
- cpu: [x64]
- os: [sunos]
-
- esbuild-windows-32@0.14.47:
- resolution:
- {
- integrity: sha512-H0fWsLTp2WBfKLBgwYT4OTfFly4Im/8B5f3ojDv1Kx//kiubVY0IQunP2Koc/fr/0wI7hj3IiBDbSrmKlrNgLQ==,
- }
- engines: { node: ">=12" }
- cpu: [ia32]
- os: [win32]
-
- esbuild-windows-64@0.14.47:
- resolution:
- {
- integrity: sha512-/Pk5jIEH34T68r8PweKRi77W49KwanZ8X6lr3vDAtOlH5EumPE4pBHqkCUdELanvsT14yMXLQ/C/8XPi1pAtkQ==,
- }
- engines: { node: ">=12" }
- cpu: [x64]
- os: [win32]
-
- esbuild-windows-arm64@0.14.47:
- resolution:
- {
- integrity: sha512-HFSW2lnp62fl86/qPQlqw6asIwCnEsEoNIL1h2uVMgakddf+vUuMcCbtUY1i8sst7KkgHrVKCJQB33YhhOweCQ==,
- }
- engines: { node: ">=12" }
- cpu: [arm64]
- os: [win32]
-
- esbuild@0.14.47:
- resolution:
- {
- integrity: sha512-wI4ZiIfFxpkuxB8ju4MHrGwGLyp1+awEHAHVpx6w7a+1pmYIq8T9FGEVVwFo0iFierDoMj++Xq69GXWYn2EiwA==,
- }
- engines: { node: ">=12" }
- hasBin: true
-
escalade@3.2.0:
resolution:
{
@@ -4463,6 +4041,13 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
+ eslint-scope@5.1.1:
+ resolution:
+ {
+ integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==,
+ }
+ engines: { node: ">=8.0.0" }
+
eslint-scope@8.4.0:
resolution:
{
@@ -4526,6 +4111,13 @@ packages:
}
engines: { node: ">=4.0" }
+ estraverse@4.3.0:
+ resolution:
+ {
+ integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==,
+ }
+ engines: { node: ">=4.0" }
+
estraverse@5.3.0:
resolution:
{
@@ -4552,13 +4144,6 @@ packages:
}
engines: { node: ">=0.10.0" }
- etag@1.8.1:
- resolution:
- {
- integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==,
- }
- engines: { node: ">= 0.6" }
-
event-target-shim@6.0.2:
resolution:
{
@@ -4566,12 +4151,6 @@ packages:
}
engines: { node: ">=10.13.0" }
- events-intercept@2.0.0:
- resolution:
- {
- integrity: sha512-blk1va0zol9QOrdZt0rFXo5KMkNPVSp92Eju/Qz8THwKWKRKeE0T8Br/1aW6+Edkyq9xHYgYxn2QtOnUKPUp+Q==,
- }
-
events@3.3.0:
resolution:
{
@@ -4579,13 +4158,6 @@ packages:
}
engines: { node: ">=0.8.x" }
- execa@3.2.0:
- resolution:
- {
- integrity: sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==,
- }
- engines: { node: ^8.12.0 || >=9.7.0 }
-
execa@5.1.1:
resolution:
{
@@ -4650,6 +4222,12 @@ packages:
integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==,
}
+ fast-uri@3.1.0:
+ resolution:
+ {
+ integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==,
+ }
+
fastq@1.19.1:
resolution:
{
@@ -4662,12 +4240,6 @@ packages:
integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==,
}
- fd-slicer@1.1.0:
- resolution:
- {
- integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==,
- }
-
fdir@6.4.6:
resolution:
{
@@ -4686,12 +4258,6 @@ packages:
}
engines: { node: ">=16.0.0" }
- file-uri-to-path@1.0.0:
- resolution:
- {
- integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==,
- }
-
fill-range@7.1.1:
resolution:
{
@@ -4777,47 +4343,12 @@ packages:
integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==,
}
- fs-extra@11.1.0:
- resolution:
- {
- integrity: sha512-0rcTq621PD5jM/e0a3EJoGC/1TC5ZBCERW82LQuwfGnCa1V8w7dpYH1yNu+SLb6E5dkeCBzKEyLGlFrnr+dUyw==,
- }
- engines: { node: ">=14.14" }
-
- fs-extra@8.1.0:
- resolution:
- {
- integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==,
- }
- engines: { node: ">=6 <7 || >=8" }
-
- fs-minipass@1.2.7:
- resolution:
- {
- integrity: sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==,
- }
-
- fs-minipass@2.1.0:
- resolution:
- {
- integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==,
- }
- engines: { node: ">= 8" }
-
fs.realpath@1.0.0:
resolution:
{
integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==,
}
- fsevents@2.1.3:
- resolution:
- {
- integrity: sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==,
- }
- engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 }
- os: [darwin]
-
fsevents@2.3.3:
resolution:
{
@@ -4845,21 +4376,6 @@ packages:
integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==,
}
- gauge@3.0.2:
- resolution:
- {
- integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==,
- }
- engines: { node: ">=10" }
- deprecated: This package is no longer supported.
-
- generic-pool@3.4.2:
- resolution:
- {
- integrity: sha512-H7cUpwCQSiJmAHM4c/aFu6fUfrhWXW1ncyh8ftxEPMu6AiYkHw9K8br720TGPZJbk5eOH2bynjZD1yPvdDAmag==,
- }
- engines: { node: ">= 4" }
-
gensync@1.0.0-beta.2:
resolution:
{
@@ -4908,13 +4424,6 @@ packages:
}
engines: { node: ">= 0.4" }
- get-stream@5.2.0:
- resolution:
- {
- integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==,
- }
- engines: { node: ">=8" }
-
get-stream@6.0.1:
resolution:
{
@@ -4949,6 +4458,12 @@ packages:
}
engines: { node: ">=10.13.0" }
+ glob-to-regexp@0.4.1:
+ resolution:
+ {
+ integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==,
+ }
+
glob@10.3.10:
resolution:
{
@@ -5068,12 +4583,6 @@ packages:
}
engines: { node: ">= 0.4" }
- has-unicode@2.0.1:
- resolution:
- {
- integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==,
- }
-
hasown@2.0.2:
resolution:
{
@@ -5117,20 +4626,6 @@ packages:
integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==,
}
- http-errors@1.4.0:
- resolution:
- {
- integrity: sha512-oLjPqve1tuOl5aRhv8GK5eHpqP1C9fb+Ol+XTLjKfLltE44zdDbEdjPSbU7Ch5rSNsVFqZn97SrMmZLdu1/YMw==,
- }
- engines: { node: ">= 0.6" }
-
- http-errors@1.7.3:
- resolution:
- {
- integrity: sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==,
- }
- engines: { node: ">= 0.6" }
-
https-proxy-agent@5.0.1:
resolution:
{
@@ -5145,13 +4640,6 @@ packages:
}
engines: { node: ">= 14" }
- human-signals@1.1.1:
- resolution:
- {
- integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==,
- }
- engines: { node: ">=8.12.0" }
-
human-signals@2.1.0:
resolution:
{
@@ -5165,13 +4653,6 @@ packages:
integrity: sha512-IvLy8MzHTSJ0fDpSzrb8rcdnla6yROEmNBSxInEMyIFu2DQkbmpadTf6B4fHvnytN6iHL2gGwpe5/jHL3wMi+A==,
}
- iconv-lite@0.4.24:
- resolution:
- {
- integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==,
- }
- engines: { node: ">=0.10.0" }
-
ieee754@1.2.1:
resolution:
{
@@ -5246,12 +4727,6 @@ packages:
}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
- inherits@2.0.1:
- resolution:
- {
- integrity: sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==,
- }
-
inherits@2.0.4:
resolution:
{
@@ -5556,12 +5031,6 @@ packages:
}
engines: { node: ">= 0.4" }
- isarray@0.0.1:
- resolution:
- {
- integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==,
- }
-
isarray@2.0.5:
resolution:
{
@@ -5819,6 +5288,13 @@ packages:
}
engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
+ jest-worker@27.5.1:
+ resolution:
+ {
+ integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==,
+ }
+ engines: { node: ">= 10.13.0" }
+
jest-worker@29.7.0:
resolution:
{
@@ -5912,12 +5388,6 @@ packages:
integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==,
}
- json-schema-to-ts@1.6.4:
- resolution:
- {
- integrity: sha512-pR4yQ9DHz6itqswtHCm26mw45FSNfQ9rEQjosaZErhn5J3J2sIViQiz8rDaezjKAhFGpmsoczYVBgGHzFw/stA==,
- }
-
json-schema-traverse@0.4.1:
resolution:
{
@@ -5951,18 +5421,6 @@ packages:
engines: { node: ">=6" }
hasBin: true
- jsonfile@4.0.0:
- resolution:
- {
- integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==,
- }
-
- jsonfile@6.2.0:
- resolution:
- {
- integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==,
- }
-
jsx-ast-utils@3.3.5:
resolution:
{
@@ -6028,6 +5486,13 @@ packages:
integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==,
}
+ loader-runner@4.3.0:
+ resolution:
+ {
+ integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==,
+ }
+ engines: { node: ">=6.11.5" }
+
localforage@1.10.0:
resolution:
{
@@ -6119,13 +5584,6 @@ packages:
}
engines: { node: ">=12" }
- make-dir@3.1.0:
- resolution:
- {
- integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==,
- }
- engines: { node: ">=8" }
-
make-dir@4.0.0:
resolution:
{
@@ -6220,14 +5678,6 @@ packages:
}
engines: { node: ">= 8" }
- micro@9.3.5-canary.3:
- resolution:
- {
- integrity: sha512-viYIo9PefV+w9dvoIBh1gI44Mvx1BOk67B4BpC2QK77qdY0xZF0Q+vWLt/BII6cLkIc8rLmSIcJaB/OrXXKe1g==,
- }
- engines: { node: ">= 8.0.0" }
- hasBin: true
-
micromark-core-commonmark@2.0.3:
resolution:
{
@@ -6408,26 +5858,6 @@ packages:
integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==,
}
- minipass@2.9.0:
- resolution:
- {
- integrity: sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==,
- }
-
- minipass@3.3.6:
- resolution:
- {
- integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==,
- }
- engines: { node: ">=8" }
-
- minipass@5.0.0:
- resolution:
- {
- integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==,
- }
- engines: { node: ">=8" }
-
minipass@7.1.2:
resolution:
{
@@ -6435,19 +5865,6 @@ packages:
}
engines: { node: ">=16 || 14 >=14.17" }
- minizlib@1.3.3:
- resolution:
- {
- integrity: sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==,
- }
-
- minizlib@2.1.2:
- resolution:
- {
- integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==,
- }
- engines: { node: ">= 8" }
-
mitt@3.0.1:
resolution:
{
@@ -6461,27 +5878,6 @@ packages:
}
hasBin: true
- mkdirp@1.0.4:
- resolution:
- {
- integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==,
- }
- engines: { node: ">=10" }
- hasBin: true
-
- mri@1.2.0:
- resolution:
- {
- integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==,
- }
- engines: { node: ">=4" }
-
- ms@2.1.1:
- resolution:
- {
- integrity: sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==,
- }
-
ms@2.1.3:
resolution:
{
@@ -6581,30 +5977,6 @@ packages:
integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==,
}
- node-fetch@2.6.7:
- resolution:
- {
- integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==,
- }
- engines: { node: 4.x || >=6.0.0 }
- peerDependencies:
- encoding: ^0.1.0
- peerDependenciesMeta:
- encoding:
- optional: true
-
- node-fetch@2.6.9:
- resolution:
- {
- integrity: sha512-DJm/CJkZkRjKKj4Zi4BsKVZh3ValV5IR5s7LVZnW+6YMh0W1BfNA8XSs6DLMGYlId5F3KnA70uu2qepcR08Qqg==,
- }
- engines: { node: 4.x || >=6.0.0 }
- peerDependencies:
- encoding: ^0.1.0
- peerDependenciesMeta:
- encoding:
- optional: true
-
node-fetch@2.7.0:
resolution:
{
@@ -6617,13 +5989,6 @@ packages:
encoding:
optional: true
- node-gyp-build@4.8.4:
- resolution:
- {
- integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==,
- }
- hasBin: true
-
node-int64@0.4.0:
resolution:
{
@@ -6636,14 +6001,6 @@ packages:
integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==,
}
- nopt@5.0.0:
- resolution:
- {
- integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==,
- }
- engines: { node: ">=6" }
- hasBin: true
-
normalize-path@3.0.0:
resolution:
{
@@ -6665,13 +6022,6 @@ packages:
}
engines: { node: ">=8" }
- npmlog@5.0.1:
- resolution:
- {
- integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==,
- }
- deprecated: This package is no longer supported.
-
nuqs@2.4.3:
resolution:
{
@@ -6776,12 +6126,6 @@ packages:
}
engines: { node: ^10.13.0 || >=12.0.0 }
- once@1.3.3:
- resolution:
- {
- integrity: sha512-6vaNInhu+CHxtONf3zw3vq4SP2DOQhjBvIa3rNcG0+P7eKWlYH6Peu7rHizSloRU2EwMz6GraLieis9Ac9+p1w==,
- }
-
once@1.4.0:
resolution:
{
@@ -6838,13 +6182,6 @@ packages:
}
engines: { node: ">= 0.8.0" }
- os-paths@4.4.0:
- resolution:
- {
- integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==,
- }
- engines: { node: ">= 6.0" }
-
own-keys@1.0.1:
resolution:
{
@@ -6852,13 +6189,6 @@ packages:
}
engines: { node: ">= 0.4" }
- p-finally@2.0.1:
- resolution:
- {
- integrity: sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==,
- }
- engines: { node: ">=8" }
-
p-limit@2.3.0:
resolution:
{
@@ -6927,19 +6257,6 @@ packages:
}
engines: { node: ">=18" }
- parse-ms@2.1.0:
- resolution:
- {
- integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==,
- }
- engines: { node: ">=6" }
-
- path-browserify@1.0.1:
- resolution:
- {
- integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==,
- }
-
path-exists@4.0.0:
resolution:
{
@@ -6961,13 +6278,6 @@ packages:
}
engines: { node: ">=8" }
- path-match@1.2.4:
- resolution:
- {
- integrity: sha512-UWlehEdqu36jmh4h5CWJ7tARp1OEVKGHKm6+dg9qMq5RKUTV5WJrGgaZ3dN2m7WFAXDbjlHzvJvL/IUpy84Ktw==,
- }
- deprecated: This package is archived and no longer maintained. For support, visit https://github.com/expressjs/express/discussions
-
path-parse@1.0.7:
resolution:
{
@@ -6981,24 +6291,6 @@ packages:
}
engines: { node: ">=16 || 14 >=14.18" }
- path-to-regexp@1.9.0:
- resolution:
- {
- integrity: sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==,
- }
-
- path-to-regexp@6.1.0:
- resolution:
- {
- integrity: sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==,
- }
-
- path-to-regexp@6.2.1:
- resolution:
- {
- integrity: sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==,
- }
-
path-type@4.0.0:
resolution:
{
@@ -7006,24 +6298,12 @@ packages:
}
engines: { node: ">=8" }
- pend@1.2.0:
- resolution:
- {
- integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==,
- }
-
perfect-freehand@1.2.2:
resolution:
{
integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==,
}
- picocolors@1.0.0:
- resolution:
- {
- integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==,
- }
-
picocolors@1.1.1:
resolution:
{
@@ -7190,13 +6470,6 @@ packages:
}
engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 }
- pretty-ms@7.0.1:
- resolution:
- {
- integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==,
- }
- engines: { node: ">=10" }
-
progress@2.0.3:
resolution:
{
@@ -7204,12 +6477,6 @@ packages:
}
engines: { node: ">=0.4.0" }
- promisepipe@3.0.0:
- resolution:
- {
- integrity: sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==,
- }
-
prop-types@15.8.1:
resolution:
{
@@ -7240,12 +6507,6 @@ packages:
integrity: sha512-VDdG/VYtOgdGkWJx7y0o7p+zArSf2383Isci8C+BP3YXgMYDoPd3cCBjw0JdWb6YBb9sFiOPbAADDVTPJnh+9g==,
}
- pump@3.0.3:
- resolution:
- {
- integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==,
- }
-
punycode@2.3.1:
resolution:
{
@@ -7277,13 +6538,6 @@ packages:
integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==,
}
- raw-body@2.4.1:
- resolution:
- {
- integrity: sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA==,
- }
- engines: { node: ">= 0.8" }
-
react-dom@18.3.1:
resolution:
{
@@ -7407,13 +6661,6 @@ packages:
}
engines: { node: ">= 6" }
- readdirp@3.3.0:
- resolution:
- {
- integrity: sha512-zz0pAkSPOXXm1viEwygWIPSPkcBYjW1xU5j/JBh5t9bGCJwa6f9+BJa6VaB2g+b55yVrmXzqkyLf4xaWYM0IkQ==,
- }
- engines: { node: ">=8.10.0" }
-
readdirp@3.6.0:
resolution:
{
@@ -7571,18 +6818,10 @@ packages:
}
engines: { iojs: ">=1.0.0", node: ">=0.10.0" }
- rimraf@3.0.2:
+ rollup@2.78.0:
resolution:
{
- integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==,
- }
- deprecated: Rimraf versions prior to v4 are no longer supported
- hasBin: true
-
- rollup@2.79.2:
- resolution:
- {
- integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==,
+ integrity: sha512-4+YfbQC9QEVvKTanHhIAFVUFSRsezvQF8vFOJwtGfb9Bb+r014S+qryr9PSmw8x6sMnPkmFBGAvIFVQxvJxjtg==,
}
engines: { node: ">=10.0.0" }
hasBin: true
@@ -7634,12 +6873,6 @@ packages:
}
engines: { node: ">= 0.4" }
- safer-buffer@2.1.2:
- resolution:
- {
- integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==,
- }
-
sass@1.90.0:
resolution:
{
@@ -7654,6 +6887,13 @@ packages:
integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==,
}
+ schema-utils@4.3.2:
+ resolution:
+ {
+ integrity: sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==,
+ }
+ engines: { node: ">= 10.13.0" }
+
sdp-transform@2.15.0:
resolution:
{
@@ -7674,14 +6914,6 @@ packages:
}
hasBin: true
- semver@7.3.5:
- resolution:
- {
- integrity: sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==,
- }
- engines: { node: ">=10" }
- hasBin: true
-
semver@7.7.2:
resolution:
{
@@ -7690,10 +6922,10 @@ packages:
engines: { node: ">=10" }
hasBin: true
- set-blocking@2.0.0:
+ serialize-javascript@6.0.2:
resolution:
{
- integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==,
+ integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==,
}
set-function-length@1.2.2:
@@ -7717,12 +6949,6 @@ packages:
}
engines: { node: ">= 0.4" }
- setprototypeof@1.1.1:
- resolution:
- {
- integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==,
- }
-
shebang-command@2.0.0:
resolution:
{
@@ -7771,13 +6997,6 @@ packages:
integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==,
}
- signal-exit@4.0.2:
- resolution:
- {
- integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==,
- }
- engines: { node: ">=14" }
-
signal-exit@4.1.0:
resolution:
{
@@ -7825,6 +7044,12 @@ packages:
integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==,
}
+ source-map-support@0.5.21:
+ resolution:
+ {
+ integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==,
+ }
+
source-map@0.5.7:
resolution:
{
@@ -7883,19 +7108,6 @@ packages:
integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==,
}
- stat-mode@0.3.0:
- resolution:
- {
- integrity: sha512-QjMLR0A3WwFY2aZdV0okfFEJB5TRjkggXZjxP3A1RsWsNHNu3YPv8btmtc6iCFZ0Rul3FE93OYogvhOUClU+ng==,
- }
-
- statuses@1.5.0:
- resolution:
- {
- integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==,
- }
- engines: { node: ">= 0.6" }
-
stop-iteration-iterator@1.1.0:
resolution:
{
@@ -7903,18 +7115,6 @@ packages:
}
engines: { node: ">= 0.4" }
- stream-to-array@2.3.0:
- resolution:
- {
- integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==,
- }
-
- stream-to-promise@2.2.0:
- resolution:
- {
- integrity: sha512-HAGUASw8NT0k8JvIVutB2Y/9iBk7gpgEyAudXwNJmZERdMITGdajOa4VJfD/kNiA3TppQpTP4J+CtcHwdzKBAw==,
- }
-
streamsearch@1.1.0:
resolution:
{
@@ -8130,19 +7330,39 @@ packages:
engines: { node: ">=14.0.0" }
hasBin: true
- tar@4.4.18:
+ tapable@2.2.3:
resolution:
{
- integrity: sha512-ZuOtqqmkV9RE1+4odd+MhBpibmCxNP6PJhH/h2OqNuotTX7/XHPZQJv2pKvWMplFH9SIZZhitehh6vBH6LO8Pg==,
+ integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==,
}
- engines: { node: ">=4.5" }
+ engines: { node: ">=6" }
- tar@6.2.1:
+ terser-webpack-plugin@5.3.14:
resolution:
{
- integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==,
+ integrity: sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==,
+ }
+ engines: { node: ">= 10.13.0" }
+ peerDependencies:
+ "@swc/core": "*"
+ esbuild: "*"
+ uglify-js: "*"
+ webpack: ^5.1.0
+ peerDependenciesMeta:
+ "@swc/core":
+ optional: true
+ esbuild:
+ optional: true
+ uglify-js:
+ optional: true
+
+ terser@5.44.0:
+ resolution:
+ {
+ integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==,
}
engines: { node: ">=10" }
+ hasBin: true
test-exclude@6.0.0:
resolution:
@@ -8164,13 +7384,6 @@ packages:
integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==,
}
- time-span@4.0.0:
- resolution:
- {
- integrity: sha512-MyqZCTGLDZ77u4k+jqg4UlrzPTPZ49NDlaekU6uuFaJLzPIN1woaRXCbGeqOfxwc3Y37ZROGAJ614Rdv7Olt+g==,
- }
- engines: { node: ">=10" }
-
tinyglobby@0.2.14:
resolution:
{
@@ -8191,26 +7404,12 @@ packages:
}
engines: { node: ">=8.0" }
- toidentifier@1.0.0:
- resolution:
- {
- integrity: sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==,
- }
- engines: { node: ">=0.6" }
-
tr46@0.0.3:
resolution:
{
integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==,
}
- tree-kill@1.2.2:
- resolution:
- {
- integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==,
- }
- hasBin: true
-
trim-lines@3.0.1:
resolution:
{
@@ -8268,12 +7467,6 @@ packages:
jest-util:
optional: true
- ts-morph@12.0.0:
- resolution:
- {
- integrity: sha512-VHC8XgU2fFW7yO1f/b3mxKDje1vmyzFXHWzOYmKEkCEwcLjDtbdLgBQviqj4ZwP4MJkQtRo6Ha2I29lq/B+VxA==,
- }
-
ts-node@10.9.1:
resolution:
{
@@ -8291,12 +7484,6 @@ packages:
"@swc/wasm":
optional: true
- ts-toolbelt@6.15.5:
- resolution:
- {
- integrity: sha512-FZIXf1ksVyLcfr7M317jbB67XFJhOO1YqdTcuGaq9q5jLUoTikukZ+98TPjKiP2jC5CgmYdWWYs0s2nLSU0/1A==,
- }
-
tsconfig-paths@3.15.0:
resolution:
{
@@ -8372,14 +7559,6 @@ packages:
}
engines: { node: ">= 0.4" }
- typescript@4.9.5:
- resolution:
- {
- integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==,
- }
- engines: { node: ">=4.2.0" }
- hasBin: true
-
typescript@5.9.2:
resolution:
{
@@ -8440,12 +7619,6 @@ packages:
integrity: sha512-o0QVGuFg24FK765Qdd5kk0zU/U4dEsCtN/GSiwNI9i8xsSVtjIAOdTaVhLwZ1nrbWxFVMxNDDl+9fednsOMsBw==,
}
- uid-promise@1.0.0:
- resolution:
- {
- integrity: sha512-R8375j0qwXyIu/7R0tjdF06/sElHqbmdmWC9M2qQHpEVbvE4I5+38KJI7LUUmQMp7NVq4tKHiBMkT0NFM453Ig==,
- }
-
umap@1.0.2:
resolution:
{
@@ -8465,13 +7638,6 @@ packages:
integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==,
}
- undici@5.28.4:
- resolution:
- {
- integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==,
- }
- engines: { node: ">=14.0" }
-
unified@11.0.5:
resolution:
{
@@ -8508,27 +7674,6 @@ packages:
integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==,
}
- universalify@0.1.2:
- resolution:
- {
- integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==,
- }
- engines: { node: ">= 4.0.0" }
-
- universalify@2.0.1:
- resolution:
- {
- integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==,
- }
- engines: { node: ">= 10.0.0" }
-
- unpipe@1.0.0:
- resolution:
- {
- integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==,
- }
- engines: { node: ">= 0.8" }
-
unrs-resolver@1.11.1:
resolution:
{
@@ -8600,14 +7745,6 @@ packages:
integrity: sha512-Fykw5U4eZESbq739BeLvEBFRuJODfrlmjx5eJux7W817LjRaq4b7/i4t2zxQmhcX+fAj4nMfRdTzO4tmwLKn0w==,
}
- uuid@3.3.2:
- resolution:
- {
- integrity: sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==,
- }
- deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
- hasBin: true
-
uuid@8.3.2:
resolution:
{
@@ -8641,14 +7778,6 @@ packages:
}
engines: { node: ">=10.12.0" }
- vercel@37.14.0:
- resolution:
- {
- integrity: sha512-ZSEvhARyJBn4YnEVZULsvti8/OHd5txRCgJqEhNIyo/XXSvBJSvlCjA+SE1zraqn0rqyEOG3+56N3kh1Enk8Tg==,
- }
- engines: { node: ">= 16" }
- hasBin: true
-
vfile-message@4.0.3:
resolution:
{
@@ -8667,18 +7796,19 @@ packages:
integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==,
}
+ watchpack@2.4.4:
+ resolution:
+ {
+ integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==,
+ }
+ engines: { node: ">=10.13.0" }
+
wavesurfer.js@7.10.1:
resolution:
{
integrity: sha512-tF1ptFCAi8SAqKbM1e7705zouLC3z4ulXCg15kSP5dQ7VDV30Q3x/xFRcuVIYTT5+jB/PdkhiBRCfsMshZG1Ug==,
}
- web-vitals@0.2.4:
- resolution:
- {
- integrity: sha512-6BjspCO9VriYy12z356nL6JBS0GYeEcA457YyRzD+dD6XYCQ75NKhcOHUMHentOE7OcVCIXXDvOm0jKFfQG2Gg==,
- }
-
webidl-conversions@3.0.1:
resolution:
{
@@ -8692,6 +7822,19 @@ packages:
}
engines: { node: ">=10.13.0" }
+ webpack@5.101.3:
+ resolution:
+ {
+ integrity: sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==,
+ }
+ engines: { node: ">=10.13.0" }
+ hasBin: true
+ peerDependencies:
+ webpack-cli: "*"
+ peerDependenciesMeta:
+ webpack-cli:
+ optional: true
+
webrtc-adapter@9.0.3:
resolution:
{
@@ -8741,12 +7884,6 @@ packages:
engines: { node: ">= 8" }
hasBin: true
- wide-align@1.1.5:
- resolution:
- {
- integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==,
- }
-
word-wrap@1.2.5:
resolution:
{
@@ -8802,20 +7939,6 @@ packages:
utf-8-validate:
optional: true
- xdg-app-paths@5.1.0:
- resolution:
- {
- integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==,
- }
- engines: { node: ">=6" }
-
- xdg-portable@7.3.0:
- resolution:
- {
- integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==,
- }
- engines: { node: ">= 6.0" }
-
xmlhttprequest-ssl@2.0.0:
resolution:
{
@@ -8877,26 +8000,6 @@ packages:
}
engines: { node: ">=12" }
- yauzl-clone@1.0.4:
- resolution:
- {
- integrity: sha512-igM2RRCf3k8TvZoxR2oguuw4z1xasOnA31joCqHIyLkeWrvAc2Jgay5ISQ2ZplinkoGaJ6orCz56Ey456c5ESA==,
- }
- engines: { node: ">=6" }
-
- yauzl-promise@2.1.3:
- resolution:
- {
- integrity: sha512-A1pf6fzh6eYkK0L4Qp7g9jzJSDrM6nN0bOn5T0IbY4Yo3w+YkWlHFkJP7mzknMXjqusHFHlKsK2N+4OLsK2MRA==,
- }
- engines: { node: ">=6" }
-
- yauzl@2.10.0:
- resolution:
- {
- integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==,
- }
-
yn@3.1.1:
resolution:
{
@@ -9227,18 +8330,7 @@ snapshots:
"@cspotcode/source-map-support@0.8.1":
dependencies:
"@jridgewell/trace-mapping": 0.3.9
-
- "@edge-runtime/format@2.2.1": {}
-
- "@edge-runtime/node-utils@2.3.0": {}
-
- "@edge-runtime/ponyfill@2.4.2": {}
-
- "@edge-runtime/primitives@4.1.0": {}
-
- "@edge-runtime/vm@3.2.0":
- dependencies:
- "@edge-runtime/primitives": 4.1.0
+ optional: true
"@emnapi/core@1.4.5":
dependencies:
@@ -9368,8 +8460,6 @@ snapshots:
"@eslint/core": 0.15.2
levn: 0.4.1
- "@fastify/busboy@2.1.1": {}
-
"@floating-ui/core@1.7.3":
dependencies:
"@floating-ui/utils": 0.2.10
@@ -9459,7 +8549,7 @@ snapshots:
jest-util: 30.0.5
slash: 3.0.0
- "@jest/core@30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))":
+ "@jest/core@30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))":
dependencies:
"@jest/console": 30.1.2
"@jest/pattern": 30.0.1
@@ -9474,7 +8564,7 @@ snapshots:
exit-x: 0.2.2
graceful-fs: 4.2.11
jest-changed-files: 30.0.5
- jest-config: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ jest-config: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
jest-haste-map: 30.1.0
jest-message-util: 30.1.0
jest-regex-util: 30.0.1
@@ -9649,6 +8739,12 @@ snapshots:
"@jridgewell/resolve-uri@3.1.2": {}
+ "@jridgewell/source-map@0.3.11":
+ dependencies:
+ "@jridgewell/gen-mapping": 0.3.13
+ "@jridgewell/trace-mapping": 0.3.30
+ optional: true
+
"@jridgewell/sourcemap-codec@1.5.5": {}
"@jridgewell/trace-mapping@0.3.30":
@@ -9660,21 +8756,7 @@ snapshots:
dependencies:
"@jridgewell/resolve-uri": 3.1.2
"@jridgewell/sourcemap-codec": 1.5.5
-
- "@mapbox/node-pre-gyp@1.0.11":
- dependencies:
- detect-libc: 2.0.4
- https-proxy-agent: 5.0.1
- make-dir: 3.1.0
- node-fetch: 2.7.0
- nopt: 5.0.0
- npmlog: 5.0.1
- rimraf: 3.0.2
- semver: 7.7.2
- tar: 6.2.1
- transitivePeerDependencies:
- - encoding
- - supports-color
+ optional: true
"@napi-rs/wasm-runtime@0.2.12":
dependencies:
@@ -9730,6 +8812,9 @@ snapshots:
"@nolyfill/is-core-module@1.0.39": {}
+ "@opentelemetry/api@1.9.0":
+ optional: true
+
"@pandacss/is-valid-prop@0.54.0": {}
"@panva/hkdf@1.2.1": {}
@@ -10013,63 +9098,42 @@ snapshots:
optionalDependencies:
react: 18.3.1
- "@rollup/plugin-commonjs@24.0.0(rollup@2.79.2)":
+ "@rollup/plugin-commonjs@24.0.0(rollup@2.78.0)":
dependencies:
- "@rollup/pluginutils": 5.2.0(rollup@2.79.2)
+ "@rollup/pluginutils": 5.2.0(rollup@2.78.0)
commondir: 1.0.1
estree-walker: 2.0.2
glob: 8.1.0
is-reference: 1.2.1
magic-string: 0.27.0
optionalDependencies:
- rollup: 2.79.2
+ rollup: 2.78.0
- "@rollup/pluginutils@4.2.1":
- dependencies:
- estree-walker: 2.0.2
- picomatch: 2.3.1
-
- "@rollup/pluginutils@5.2.0(rollup@2.79.2)":
+ "@rollup/pluginutils@5.2.0(rollup@2.78.0)":
dependencies:
"@types/estree": 1.0.8
estree-walker: 2.0.2
picomatch: 4.0.3
optionalDependencies:
- rollup: 2.79.2
+ rollup: 2.78.0
"@rtsao/scc@1.1.0": {}
"@rushstack/eslint-patch@1.12.0": {}
- "@sentry-internal/feedback@7.120.4":
+ "@sentry-internal/tracing@7.77.0":
dependencies:
- "@sentry/core": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
+ "@sentry/core": 7.77.0
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
- "@sentry-internal/replay-canvas@7.120.4":
+ "@sentry/browser@7.77.0":
dependencies:
- "@sentry/core": 7.120.4
- "@sentry/replay": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
-
- "@sentry-internal/tracing@7.120.4":
- dependencies:
- "@sentry/core": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
-
- "@sentry/browser@7.120.4":
- dependencies:
- "@sentry-internal/feedback": 7.120.4
- "@sentry-internal/replay-canvas": 7.120.4
- "@sentry-internal/tracing": 7.120.4
- "@sentry/core": 7.120.4
- "@sentry/integrations": 7.120.4
- "@sentry/replay": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
+ "@sentry-internal/tracing": 7.77.0
+ "@sentry/core": 7.77.0
+ "@sentry/replay": 7.77.0
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
"@sentry/cli@1.77.3":
dependencies:
@@ -10083,78 +9147,79 @@ snapshots:
- encoding
- supports-color
- "@sentry/core@7.120.4":
+ "@sentry/core@7.77.0":
dependencies:
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
- "@sentry/integrations@7.120.4":
+ "@sentry/integrations@7.77.0":
dependencies:
- "@sentry/core": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
+ "@sentry/core": 7.77.0
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
localforage: 1.10.0
- "@sentry/nextjs@7.120.4(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)":
+ "@sentry/nextjs@7.77.0(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)":
dependencies:
- "@rollup/plugin-commonjs": 24.0.0(rollup@2.79.2)
- "@sentry/core": 7.120.4
- "@sentry/integrations": 7.120.4
- "@sentry/node": 7.120.4
- "@sentry/react": 7.120.4(react@18.3.1)
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
- "@sentry/vercel-edge": 7.120.4
- "@sentry/webpack-plugin": 1.21.0
+ "@rollup/plugin-commonjs": 24.0.0(rollup@2.78.0)
+ "@sentry/core": 7.77.0
+ "@sentry/integrations": 7.77.0
+ "@sentry/node": 7.77.0
+ "@sentry/react": 7.77.0(react@18.3.1)
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
+ "@sentry/vercel-edge": 7.77.0
+ "@sentry/webpack-plugin": 1.20.0
chalk: 3.0.0
- next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ next: 14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
react: 18.3.1
resolve: 1.22.8
- rollup: 2.79.2
+ rollup: 2.78.0
stacktrace-parser: 0.1.11
+ optionalDependencies:
+ webpack: 5.101.3
transitivePeerDependencies:
- encoding
- supports-color
- "@sentry/node@7.120.4":
+ "@sentry/node@7.77.0":
dependencies:
- "@sentry-internal/tracing": 7.120.4
- "@sentry/core": 7.120.4
- "@sentry/integrations": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
+ "@sentry-internal/tracing": 7.77.0
+ "@sentry/core": 7.77.0
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
+ https-proxy-agent: 5.0.1
+ transitivePeerDependencies:
+ - supports-color
- "@sentry/react@7.120.4(react@18.3.1)":
+ "@sentry/react@7.77.0(react@18.3.1)":
dependencies:
- "@sentry/browser": 7.120.4
- "@sentry/core": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
+ "@sentry/browser": 7.77.0
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
hoist-non-react-statics: 3.3.2
react: 18.3.1
- "@sentry/replay@7.120.4":
+ "@sentry/replay@7.77.0":
dependencies:
- "@sentry-internal/tracing": 7.120.4
- "@sentry/core": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
+ "@sentry-internal/tracing": 7.77.0
+ "@sentry/core": 7.77.0
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
- "@sentry/types@7.120.4": {}
+ "@sentry/types@7.77.0": {}
- "@sentry/utils@7.120.4":
+ "@sentry/utils@7.77.0":
dependencies:
- "@sentry/types": 7.120.4
+ "@sentry/types": 7.77.0
- "@sentry/vercel-edge@7.120.4":
+ "@sentry/vercel-edge@7.77.0":
dependencies:
- "@sentry-internal/tracing": 7.120.4
- "@sentry/core": 7.120.4
- "@sentry/integrations": 7.120.4
- "@sentry/types": 7.120.4
- "@sentry/utils": 7.120.4
+ "@sentry/core": 7.77.0
+ "@sentry/types": 7.77.0
+ "@sentry/utils": 7.77.0
- "@sentry/webpack-plugin@1.21.0":
+ "@sentry/webpack-plugin@1.20.0":
dependencies:
"@sentry/cli": 1.77.3
webpack-sources: 3.3.3
@@ -10162,8 +9227,6 @@ snapshots:
- encoding
- supports-color
- "@sinclair/typebox@0.25.24": {}
-
"@sinclair/typebox@0.27.8": {}
"@sinclair/typebox@0.34.41": {}
@@ -10200,22 +9263,17 @@ snapshots:
"@tanstack/query-core": 5.85.9
react: 18.3.1
- "@tootallnate/once@2.0.0": {}
+ "@tsconfig/node10@1.0.11":
+ optional: true
- "@ts-morph/common@0.11.1":
- dependencies:
- fast-glob: 3.3.3
- minimatch: 3.1.2
- mkdirp: 1.0.4
- path-browserify: 1.0.1
+ "@tsconfig/node12@1.0.11":
+ optional: true
- "@tsconfig/node10@1.0.11": {}
+ "@tsconfig/node14@1.0.3":
+ optional: true
- "@tsconfig/node12@1.0.11": {}
-
- "@tsconfig/node14@1.0.3": {}
-
- "@tsconfig/node16@1.0.4": {}
+ "@tsconfig/node16@1.0.4":
+ optional: true
"@tybys/wasm-util@0.10.0":
dependencies:
@@ -10247,6 +9305,18 @@ snapshots:
dependencies:
"@types/ms": 2.1.0
+ "@types/eslint-scope@3.7.7":
+ dependencies:
+ "@types/eslint": 9.6.1
+ "@types/estree": 1.0.8
+ optional: true
+
+ "@types/eslint@9.6.1":
+ dependencies:
+ "@types/estree": 1.0.8
+ "@types/json-schema": 7.0.15
+ optional: true
+
"@types/estree-jsx@1.0.5":
dependencies:
"@types/estree": 1.0.8
@@ -10295,8 +9365,6 @@ snapshots:
"@types/node": 24.2.1
form-data: 4.0.4
- "@types/node@16.18.11": {}
-
"@types/node@24.2.1":
dependencies:
undici-types: 7.10.0
@@ -10493,158 +9561,96 @@ snapshots:
"@unrs/resolver-binding-win32-x64-msvc@1.11.1":
optional: true
- "@vercel/build-utils@8.4.12": {}
-
- "@vercel/edge-config-fs@0.1.0": {}
-
- "@vercel/edge-config@0.4.1":
+ "@webassemblyjs/ast@1.14.1":
dependencies:
- "@vercel/edge-config-fs": 0.1.0
+ "@webassemblyjs/helper-numbers": 1.13.2
+ "@webassemblyjs/helper-wasm-bytecode": 1.13.2
+ optional: true
- "@vercel/error-utils@2.0.2": {}
+ "@webassemblyjs/floating-point-hex-parser@1.13.2":
+ optional: true
- "@vercel/fun@1.1.0":
+ "@webassemblyjs/helper-api-error@1.13.2":
+ optional: true
+
+ "@webassemblyjs/helper-buffer@1.14.1":
+ optional: true
+
+ "@webassemblyjs/helper-numbers@1.13.2":
dependencies:
- "@tootallnate/once": 2.0.0
- async-listen: 1.2.0
- debug: 4.1.1
- execa: 3.2.0
- fs-extra: 8.1.0
- generic-pool: 3.4.2
- micro: 9.3.5-canary.3
- ms: 2.1.1
- node-fetch: 2.6.7
- path-match: 1.2.4
- promisepipe: 3.0.0
- semver: 7.3.5
- stat-mode: 0.3.0
- stream-to-promise: 2.2.0
- tar: 4.4.18
- tree-kill: 1.2.2
- uid-promise: 1.0.0
- uuid: 3.3.2
- xdg-app-paths: 5.1.0
- yauzl-promise: 2.1.3
- transitivePeerDependencies:
- - encoding
- - supports-color
+ "@webassemblyjs/floating-point-hex-parser": 1.13.2
+ "@webassemblyjs/helper-api-error": 1.13.2
+ "@xtuc/long": 4.2.2
+ optional: true
- "@vercel/gatsby-plugin-vercel-analytics@1.0.11":
+ "@webassemblyjs/helper-wasm-bytecode@1.13.2":
+ optional: true
+
+ "@webassemblyjs/helper-wasm-section@1.14.1":
dependencies:
- web-vitals: 0.2.4
+ "@webassemblyjs/ast": 1.14.1
+ "@webassemblyjs/helper-buffer": 1.14.1
+ "@webassemblyjs/helper-wasm-bytecode": 1.13.2
+ "@webassemblyjs/wasm-gen": 1.14.1
+ optional: true
- "@vercel/gatsby-plugin-vercel-builder@2.0.56":
+ "@webassemblyjs/ieee754@1.13.2":
dependencies:
- "@sinclair/typebox": 0.25.24
- "@vercel/build-utils": 8.4.12
- "@vercel/routing-utils": 3.1.0
- esbuild: 0.14.47
- etag: 1.8.1
- fs-extra: 11.1.0
+ "@xtuc/ieee754": 1.2.0
+ optional: true
- "@vercel/go@3.2.0": {}
-
- "@vercel/hydrogen@1.0.9":
+ "@webassemblyjs/leb128@1.13.2":
dependencies:
- "@vercel/static-config": 3.0.0
- ts-morph: 12.0.0
+ "@xtuc/long": 4.2.2
+ optional: true
- "@vercel/next@4.3.18":
+ "@webassemblyjs/utf8@1.13.2":
+ optional: true
+
+ "@webassemblyjs/wasm-edit@1.14.1":
dependencies:
- "@vercel/nft": 0.27.3
- transitivePeerDependencies:
- - encoding
- - supports-color
+ "@webassemblyjs/ast": 1.14.1
+ "@webassemblyjs/helper-buffer": 1.14.1
+ "@webassemblyjs/helper-wasm-bytecode": 1.13.2
+ "@webassemblyjs/helper-wasm-section": 1.14.1
+ "@webassemblyjs/wasm-gen": 1.14.1
+ "@webassemblyjs/wasm-opt": 1.14.1
+ "@webassemblyjs/wasm-parser": 1.14.1
+ "@webassemblyjs/wast-printer": 1.14.1
+ optional: true
- "@vercel/nft@0.27.3":
+ "@webassemblyjs/wasm-gen@1.14.1":
dependencies:
- "@mapbox/node-pre-gyp": 1.0.11
- "@rollup/pluginutils": 4.2.1
- acorn: 8.15.0
- acorn-import-attributes: 1.9.5(acorn@8.15.0)
- async-sema: 3.1.1
- bindings: 1.5.0
- estree-walker: 2.0.2
- glob: 7.2.3
- graceful-fs: 4.2.11
- micromatch: 4.0.8
- node-gyp-build: 4.8.4
- resolve-from: 5.0.0
- transitivePeerDependencies:
- - encoding
- - supports-color
+ "@webassemblyjs/ast": 1.14.1
+ "@webassemblyjs/helper-wasm-bytecode": 1.13.2
+ "@webassemblyjs/ieee754": 1.13.2
+ "@webassemblyjs/leb128": 1.13.2
+ "@webassemblyjs/utf8": 1.13.2
+ optional: true
- "@vercel/node@3.2.24":
+ "@webassemblyjs/wasm-opt@1.14.1":
dependencies:
- "@edge-runtime/node-utils": 2.3.0
- "@edge-runtime/primitives": 4.1.0
- "@edge-runtime/vm": 3.2.0
- "@types/node": 16.18.11
- "@vercel/build-utils": 8.4.12
- "@vercel/error-utils": 2.0.2
- "@vercel/nft": 0.27.3
- "@vercel/static-config": 3.0.0
- async-listen: 3.0.0
- cjs-module-lexer: 1.2.3
- edge-runtime: 2.5.9
- es-module-lexer: 1.4.1
- esbuild: 0.14.47
- etag: 1.8.1
- node-fetch: 2.6.9
- path-to-regexp: 6.2.1
- ts-morph: 12.0.0
- ts-node: 10.9.1(@types/node@16.18.11)(typescript@4.9.5)
- typescript: 4.9.5
- undici: 5.28.4
- transitivePeerDependencies:
- - "@swc/core"
- - "@swc/wasm"
- - encoding
- - supports-color
+ "@webassemblyjs/ast": 1.14.1
+ "@webassemblyjs/helper-buffer": 1.14.1
+ "@webassemblyjs/wasm-gen": 1.14.1
+ "@webassemblyjs/wasm-parser": 1.14.1
+ optional: true
- "@vercel/python@4.3.1": {}
-
- "@vercel/redwood@2.1.8":
+ "@webassemblyjs/wasm-parser@1.14.1":
dependencies:
- "@vercel/nft": 0.27.3
- "@vercel/routing-utils": 3.1.0
- "@vercel/static-config": 3.0.0
- semver: 6.3.1
- ts-morph: 12.0.0
- transitivePeerDependencies:
- - encoding
- - supports-color
+ "@webassemblyjs/ast": 1.14.1
+ "@webassemblyjs/helper-api-error": 1.13.2
+ "@webassemblyjs/helper-wasm-bytecode": 1.13.2
+ "@webassemblyjs/ieee754": 1.13.2
+ "@webassemblyjs/leb128": 1.13.2
+ "@webassemblyjs/utf8": 1.13.2
+ optional: true
- "@vercel/remix-builder@2.2.13":
+ "@webassemblyjs/wast-printer@1.14.1":
dependencies:
- "@vercel/error-utils": 2.0.2
- "@vercel/nft": 0.27.3
- "@vercel/static-config": 3.0.0
- ts-morph: 12.0.0
- transitivePeerDependencies:
- - encoding
- - supports-color
-
- "@vercel/routing-utils@3.1.0":
- dependencies:
- path-to-regexp: 6.1.0
- optionalDependencies:
- ajv: 6.12.6
-
- "@vercel/ruby@2.1.0": {}
-
- "@vercel/static-build@2.5.34":
- dependencies:
- "@vercel/gatsby-plugin-vercel-analytics": 1.0.11
- "@vercel/gatsby-plugin-vercel-builder": 2.0.56
- "@vercel/static-config": 3.0.0
- ts-morph: 12.0.0
-
- "@vercel/static-config@3.0.0":
- dependencies:
- ajv: 8.6.3
- json-schema-to-ts: 1.6.4
- ts-morph: 12.0.0
+ "@webassemblyjs/ast": 1.14.1
+ "@xtuc/long": 4.2.2
+ optional: true
"@whereby.com/browser-sdk@3.13.1(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)":
dependencies:
@@ -10703,6 +9709,12 @@ snapshots:
- supports-color
- utf-8-validate
+ "@xtuc/ieee754@1.2.0":
+ optional: true
+
+ "@xtuc/long@4.2.2":
+ optional: true
+
"@zag-js/accordion@1.21.0":
dependencies:
"@zag-js/anatomy": 1.21.0
@@ -11204,11 +10216,10 @@ snapshots:
"@zag-js/utils@1.21.0": {}
- abbrev@1.1.1: {}
-
- acorn-import-attributes@1.9.5(acorn@8.15.0):
+ acorn-import-phases@1.0.4(acorn@8.15.0):
dependencies:
acorn: 8.15.0
+ optional: true
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
@@ -11217,6 +10228,7 @@ snapshots:
acorn-walk@8.3.4:
dependencies:
acorn: 8.15.0
+ optional: true
acorn@8.15.0: {}
@@ -11228,6 +10240,17 @@ snapshots:
agent-base@7.1.4: {}
+ ajv-formats@2.1.1(ajv@8.17.1):
+ optionalDependencies:
+ ajv: 8.17.1
+ optional: true
+
+ ajv-keywords@5.1.0(ajv@8.17.1):
+ dependencies:
+ ajv: 8.17.1
+ fast-deep-equal: 3.1.3
+ optional: true
+
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
@@ -11235,12 +10258,13 @@ snapshots:
json-schema-traverse: 0.4.1
uri-js: 4.4.1
- ajv@8.6.3:
+ ajv@8.17.1:
dependencies:
fast-deep-equal: 3.1.3
+ fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
- uri-js: 4.4.1
+ optional: true
ansi-colors@4.1.3: {}
@@ -11267,16 +10291,8 @@ snapshots:
normalize-path: 3.0.0
picomatch: 2.3.1
- aproba@2.1.0: {}
-
- are-we-there-yet@2.0.0:
- dependencies:
- delegates: 1.0.0
- readable-stream: 3.6.2
-
- arg@4.1.0: {}
-
- arg@4.1.3: {}
+ arg@4.1.3:
+ optional: true
arg@5.0.2: {}
@@ -11363,14 +10379,6 @@ snapshots:
async-function@1.0.0: {}
- async-listen@1.2.0: {}
-
- async-listen@3.0.0: {}
-
- async-listen@3.0.1: {}
-
- async-sema@3.1.1: {}
-
asynckit@0.4.0: {}
augmentor@2.2.0:
@@ -11478,10 +10486,6 @@ snapshots:
binary-extensions@2.3.0: {}
- bindings@1.5.0:
- dependencies:
- file-uri-to-path: 1.0.0
-
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -11512,8 +10516,6 @@ snapshots:
btoa@1.2.1: {}
- buffer-crc32@0.2.13: {}
-
buffer-from@1.1.2: {}
buffer@6.0.3:
@@ -11525,8 +10527,6 @@ snapshots:
dependencies:
streamsearch: 1.1.0
- bytes@3.1.0: {}
-
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -11582,18 +10582,6 @@ snapshots:
dependencies:
ip-range-check: 0.0.2
- chokidar@3.3.1:
- dependencies:
- anymatch: 3.1.3
- braces: 3.0.3
- glob-parent: 5.1.2
- is-binary-path: 2.1.0
- is-glob: 4.0.3
- normalize-path: 3.0.0
- readdirp: 3.3.0
- optionalDependencies:
- fsevents: 2.1.3
-
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@@ -11610,16 +10598,13 @@ snapshots:
dependencies:
readdirp: 4.1.2
- chownr@1.1.4: {}
-
- chownr@2.0.0: {}
+ chrome-trace-event@1.0.4:
+ optional: true
ci-info@3.9.0: {}
ci-info@4.3.0: {}
- cjs-module-lexer@1.2.3: {}
-
cjs-module-lexer@2.1.0: {}
classnames@2.5.1: {}
@@ -11638,8 +10623,6 @@ snapshots:
co@4.6.0: {}
- code-block-writer@10.1.1: {}
-
collect-v8-coverage@1.0.2: {}
color-convert@2.0.1:
@@ -11648,8 +10631,6 @@ snapshots:
color-name@1.1.4: {}
- color-support@1.1.3: {}
-
colorette@1.4.0: {}
combined-stream@1.0.8:
@@ -11658,18 +10639,15 @@ snapshots:
comma-separated-tokens@2.0.3: {}
+ commander@2.20.3:
+ optional: true
+
commander@4.1.1: {}
commondir@1.0.1: {}
concat-map@0.0.1: {}
- console-control-strings@1.1.0: {}
-
- content-type@1.0.4: {}
-
- convert-hrtime@3.0.0: {}
-
convert-source-map@1.9.0: {}
convert-source-map@2.0.0: {}
@@ -11684,7 +10662,8 @@ snapshots:
path-type: 4.0.0
yaml: 1.10.2
- create-require@1.1.1: {}
+ create-require@1.1.1:
+ optional: true
cross-spawn@7.0.6:
dependencies:
@@ -11720,10 +10699,6 @@ snapshots:
dependencies:
ms: 2.1.3
- debug@4.1.1:
- dependencies:
- ms: 2.1.1
-
debug@4.3.7:
dependencies:
ms: 2.1.3
@@ -11766,12 +10741,8 @@ snapshots:
delayed-stream@1.0.0: {}
- delegates@1.0.0: {}
-
denque@2.1.0: {}
- depd@1.1.2: {}
-
dequal@2.0.3: {}
detect-europe-js@0.1.2: {}
@@ -11779,8 +10750,6 @@ snapshots:
detect-libc@1.0.3:
optional: true
- detect-libc@2.0.4: {}
-
detect-newline@3.1.0: {}
detect-node-es@1.1.0: {}
@@ -11791,7 +10760,8 @@ snapshots:
didyoumean@1.2.2: {}
- diff@4.0.2: {}
+ diff@4.0.2:
+ optional: true
dlv@1.1.3: {}
@@ -11823,18 +10793,6 @@ snapshots:
eastasianwidth@0.2.0: {}
- edge-runtime@2.5.9:
- dependencies:
- "@edge-runtime/format": 2.2.1
- "@edge-runtime/ponyfill": 2.4.2
- "@edge-runtime/vm": 3.2.0
- async-listen: 3.0.1
- mri: 1.2.0
- picocolors: 1.0.0
- pretty-ms: 7.0.1
- signal-exit: 4.0.2
- time-span: 4.0.0
-
electron-to-chromium@1.5.200: {}
emittery@0.13.1: {}
@@ -11843,14 +10801,6 @@ snapshots:
emoji-regex@9.2.2: {}
- end-of-stream@1.1.0:
- dependencies:
- once: 1.3.3
-
- end-of-stream@1.4.5:
- dependencies:
- once: 1.4.0
-
engine.io-client@6.5.4:
dependencies:
"@socket.io/component-emitter": 3.1.2
@@ -11865,6 +10815,12 @@ snapshots:
engine.io-parser@5.2.3: {}
+ enhanced-resolve@5.18.3:
+ dependencies:
+ graceful-fs: 4.2.11
+ tapable: 2.2.3
+ optional: true
+
err-code@3.0.1: {}
error-ex@1.3.2:
@@ -11951,7 +10907,8 @@ snapshots:
iterator.prototype: 1.1.5
safe-array-concat: 1.1.3
- es-module-lexer@1.4.1: {}
+ es-module-lexer@1.7.0:
+ optional: true
es-object-atoms@1.1.1:
dependencies:
@@ -11974,89 +10931,6 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
- esbuild-android-64@0.14.47:
- optional: true
-
- esbuild-android-arm64@0.14.47:
- optional: true
-
- esbuild-darwin-64@0.14.47:
- optional: true
-
- esbuild-darwin-arm64@0.14.47:
- optional: true
-
- esbuild-freebsd-64@0.14.47:
- optional: true
-
- esbuild-freebsd-arm64@0.14.47:
- optional: true
-
- esbuild-linux-32@0.14.47:
- optional: true
-
- esbuild-linux-64@0.14.47:
- optional: true
-
- esbuild-linux-arm64@0.14.47:
- optional: true
-
- esbuild-linux-arm@0.14.47:
- optional: true
-
- esbuild-linux-mips64le@0.14.47:
- optional: true
-
- esbuild-linux-ppc64le@0.14.47:
- optional: true
-
- esbuild-linux-riscv64@0.14.47:
- optional: true
-
- esbuild-linux-s390x@0.14.47:
- optional: true
-
- esbuild-netbsd-64@0.14.47:
- optional: true
-
- esbuild-openbsd-64@0.14.47:
- optional: true
-
- esbuild-sunos-64@0.14.47:
- optional: true
-
- esbuild-windows-32@0.14.47:
- optional: true
-
- esbuild-windows-64@0.14.47:
- optional: true
-
- esbuild-windows-arm64@0.14.47:
- optional: true
-
- esbuild@0.14.47:
- optionalDependencies:
- esbuild-android-64: 0.14.47
- esbuild-android-arm64: 0.14.47
- esbuild-darwin-64: 0.14.47
- esbuild-darwin-arm64: 0.14.47
- esbuild-freebsd-64: 0.14.47
- esbuild-freebsd-arm64: 0.14.47
- esbuild-linux-32: 0.14.47
- esbuild-linux-64: 0.14.47
- esbuild-linux-arm: 0.14.47
- esbuild-linux-arm64: 0.14.47
- esbuild-linux-mips64le: 0.14.47
- esbuild-linux-ppc64le: 0.14.47
- esbuild-linux-riscv64: 0.14.47
- esbuild-linux-s390x: 0.14.47
- esbuild-netbsd-64: 0.14.47
- esbuild-openbsd-64: 0.14.47
- esbuild-sunos-64: 0.14.47
- esbuild-windows-32: 0.14.47
- esbuild-windows-64: 0.14.47
- esbuild-windows-arm64: 0.14.47
-
escalade@3.2.0: {}
escape-string-regexp@2.0.0: {}
@@ -12191,6 +11065,12 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
+ eslint-scope@5.1.1:
+ dependencies:
+ esrecurse: 4.3.0
+ estraverse: 4.3.0
+ optional: true
+
eslint-scope@8.4.0:
dependencies:
esrecurse: 4.3.0
@@ -12258,6 +11138,9 @@ snapshots:
dependencies:
estraverse: 5.3.0
+ estraverse@4.3.0:
+ optional: true
+
estraverse@5.3.0: {}
estree-util-is-identifier-name@3.0.0: {}
@@ -12266,27 +11149,10 @@ snapshots:
esutils@2.0.3: {}
- etag@1.8.1: {}
-
event-target-shim@6.0.2: {}
- events-intercept@2.0.0: {}
-
events@3.3.0: {}
- execa@3.2.0:
- dependencies:
- cross-spawn: 7.0.6
- get-stream: 5.2.0
- human-signals: 1.1.1
- is-stream: 2.0.1
- merge-stream: 2.0.0
- npm-run-path: 4.0.1
- onetime: 5.1.2
- p-finally: 2.0.1
- signal-exit: 3.0.7
- strip-final-newline: 2.0.0
-
execa@5.1.1:
dependencies:
cross-spawn: 7.0.6
@@ -12333,6 +11199,9 @@ snapshots:
fast-safe-stringify@2.1.1: {}
+ fast-uri@3.1.0:
+ optional: true
+
fastq@1.19.1:
dependencies:
reusify: 1.1.0
@@ -12341,10 +11210,6 @@ snapshots:
dependencies:
bser: 2.1.1
- fd-slicer@1.1.0:
- dependencies:
- pend: 1.2.0
-
fdir@6.4.6(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -12353,8 +11218,6 @@ snapshots:
dependencies:
flat-cache: 4.0.1
- file-uri-to-path@1.0.0: {}
-
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@@ -12401,31 +11264,8 @@ snapshots:
fraction.js@4.3.7: {}
- fs-extra@11.1.0:
- dependencies:
- graceful-fs: 4.2.11
- jsonfile: 6.2.0
- universalify: 2.0.1
-
- fs-extra@8.1.0:
- dependencies:
- graceful-fs: 4.2.11
- jsonfile: 4.0.0
- universalify: 0.1.2
-
- fs-minipass@1.2.7:
- dependencies:
- minipass: 2.9.0
-
- fs-minipass@2.1.0:
- dependencies:
- minipass: 3.3.6
-
fs.realpath@1.0.0: {}
- fsevents@2.1.3:
- optional: true
-
fsevents@2.3.3:
optional: true
@@ -12442,20 +11282,6 @@ snapshots:
functions-have-names@1.2.3: {}
- gauge@3.0.2:
- dependencies:
- aproba: 2.1.0
- color-support: 1.1.3
- console-control-strings: 1.1.0
- has-unicode: 2.0.1
- object-assign: 4.1.1
- signal-exit: 3.0.7
- string-width: 4.2.3
- strip-ansi: 6.0.1
- wide-align: 1.1.5
-
- generic-pool@3.4.2: {}
-
gensync@1.0.0-beta.2: {}
get-browser-rtc@1.1.0: {}
@@ -12484,10 +11310,6 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
- get-stream@5.2.0:
- dependencies:
- pump: 3.0.3
-
get-stream@6.0.1: {}
get-symbol-description@1.1.0:
@@ -12508,6 +11330,9 @@ snapshots:
dependencies:
is-glob: 4.0.3
+ glob-to-regexp@0.4.1:
+ optional: true
+
glob@10.3.10:
dependencies:
foreground-child: 3.3.1
@@ -12588,8 +11413,6 @@ snapshots:
dependencies:
has-symbols: 1.1.0
- has-unicode@2.0.1: {}
-
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -12635,19 +11458,6 @@ snapshots:
html-url-attributes@3.0.1: {}
- http-errors@1.4.0:
- dependencies:
- inherits: 2.0.1
- statuses: 1.5.0
-
- http-errors@1.7.3:
- dependencies:
- depd: 1.1.2
- inherits: 2.0.4
- setprototypeof: 1.1.1
- statuses: 1.5.0
- toidentifier: 1.0.0
-
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
@@ -12662,16 +11472,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
- human-signals@1.1.1: {}
-
human-signals@2.1.0: {}
hyperhtml-style@0.1.3: {}
- iconv-lite@0.4.24:
- dependencies:
- safer-buffer: 2.1.2
-
ieee754@1.2.1: {}
ignore@5.3.2: {}
@@ -12703,8 +11507,6 @@ snapshots:
once: 1.4.0
wrappy: 1.0.2
- inherits@2.0.1: {}
-
inherits@2.0.4: {}
inline-style-parser@0.2.4: {}
@@ -12882,8 +11684,6 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
- isarray@0.0.1: {}
-
isarray@2.0.5: {}
isexe@2.0.0: {}
@@ -12972,15 +11772,15 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ jest-cli@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)):
dependencies:
- "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
"@jest/test-result": 30.1.3
"@jest/types": 30.0.5
chalk: 4.1.2
exit-x: 0.2.2
import-local: 3.2.0
- jest-config: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ jest-config: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
jest-util: 30.0.5
jest-validate: 30.1.0
yargs: 17.7.2
@@ -12991,40 +11791,7 @@ snapshots:
- supports-color
- ts-node
- jest-config@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
- dependencies:
- "@babel/core": 7.28.3
- "@jest/get-type": 30.1.0
- "@jest/pattern": 30.0.1
- "@jest/test-sequencer": 30.1.3
- "@jest/types": 30.0.5
- babel-jest: 30.1.2(@babel/core@7.28.3)
- chalk: 4.1.2
- ci-info: 4.3.0
- deepmerge: 4.3.1
- glob: 10.4.5
- graceful-fs: 4.2.11
- jest-circus: 30.1.3(babel-plugin-macros@3.1.0)
- jest-docblock: 30.0.1
- jest-environment-node: 30.1.2
- jest-regex-util: 30.0.1
- jest-resolve: 30.1.3
- jest-runner: 30.1.3
- jest-util: 30.0.5
- jest-validate: 30.1.0
- micromatch: 4.0.8
- parse-json: 5.2.0
- pretty-format: 30.0.5
- slash: 3.0.0
- strip-json-comments: 3.1.1
- optionalDependencies:
- "@types/node": 16.18.11
- ts-node: 10.9.1(@types/node@16.18.11)(typescript@5.9.2)
- transitivePeerDependencies:
- - babel-plugin-macros
- - supports-color
-
- jest-config@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ jest-config@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)):
dependencies:
"@babel/core": 7.28.3
"@jest/get-type": 30.1.0
@@ -13052,7 +11819,7 @@ snapshots:
strip-json-comments: 3.1.1
optionalDependencies:
"@types/node": 24.2.1
- ts-node: 10.9.1(@types/node@16.18.11)(typescript@5.9.2)
+ ts-node: 10.9.1(@types/node@24.2.1)(typescript@5.9.2)
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -13273,6 +12040,13 @@ snapshots:
jest-util: 30.0.5
string-length: 4.0.2
+ jest-worker@27.5.1:
+ dependencies:
+ "@types/node": 24.2.1
+ merge-stream: 2.0.0
+ supports-color: 8.1.1
+ optional: true
+
jest-worker@29.7.0:
dependencies:
"@types/node": 24.2.1
@@ -13288,12 +12062,12 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ jest@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)):
dependencies:
- "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ "@jest/core": 30.1.3(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
"@jest/types": 30.0.5
import-local: 3.2.0
- jest-cli: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ jest-cli: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
transitivePeerDependencies:
- "@types/node"
- babel-plugin-macros
@@ -13326,11 +12100,6 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
- json-schema-to-ts@1.6.4:
- dependencies:
- "@types/json-schema": 7.0.15
- ts-toolbelt: 6.15.5
-
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -13343,16 +12112,6 @@ snapshots:
json5@2.2.3: {}
- jsonfile@4.0.0:
- optionalDependencies:
- graceful-fs: 4.2.11
-
- jsonfile@6.2.0:
- dependencies:
- universalify: 2.0.1
- optionalDependencies:
- graceful-fs: 4.2.11
-
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9
@@ -13397,6 +12156,9 @@ snapshots:
lines-and-columns@1.2.4: {}
+ loader-runner@4.3.0:
+ optional: true
+
localforage@1.10.0:
dependencies:
lie: 3.1.1
@@ -13441,10 +12203,6 @@ snapshots:
dependencies:
"@jridgewell/sourcemap-codec": 1.5.5
- make-dir@3.1.0:
- dependencies:
- semver: 6.3.1
-
make-dir@4.0.0:
dependencies:
semver: 7.7.2
@@ -13566,12 +12324,6 @@ snapshots:
merge2@1.4.1: {}
- micro@9.3.5-canary.3:
- dependencies:
- arg: 4.1.0
- content-type: 1.0.4
- raw-body: 2.4.1
-
micromark-core-commonmark@2.0.3:
dependencies:
decode-named-character-reference: 1.2.0
@@ -13732,40 +12484,14 @@ snapshots:
minimist@1.2.8: {}
- minipass@2.9.0:
- dependencies:
- safe-buffer: 5.2.1
- yallist: 3.1.1
-
- minipass@3.3.6:
- dependencies:
- yallist: 4.0.0
-
- minipass@5.0.0: {}
-
minipass@7.1.2: {}
- minizlib@1.3.3:
- dependencies:
- minipass: 2.9.0
-
- minizlib@2.1.2:
- dependencies:
- minipass: 3.3.6
- yallist: 4.0.0
-
mitt@3.0.1: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
- mkdirp@1.0.4: {}
-
- mri@1.2.0: {}
-
- ms@2.1.1: {}
-
ms@2.1.3: {}
mz@2.7.0:
@@ -13782,13 +12508,13 @@ snapshots:
neo-async@2.6.2: {}
- next-auth@4.24.11(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ next-auth@4.24.11(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
"@babel/runtime": 7.28.2
"@panva/hkdf": 1.2.1
cookie: 0.7.2
jose: 4.15.9
- next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ next: 14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.27.0
@@ -13802,7 +12528,7 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0):
+ next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0):
dependencies:
"@next/env": 14.2.31
"@swc/helpers": 0.5.5
@@ -13823,6 +12549,7 @@ snapshots:
"@next/swc-win32-arm64-msvc": 14.2.31
"@next/swc-win32-ia32-msvc": 14.2.31
"@next/swc-win32-x64-msvc": 14.2.31
+ "@opentelemetry/api": 1.9.0
sass: 1.90.0
transitivePeerDependencies:
- "@babel/core"
@@ -13833,28 +12560,14 @@ snapshots:
node-addon-api@7.1.1:
optional: true
- node-fetch@2.6.7:
- dependencies:
- whatwg-url: 5.0.0
-
- node-fetch@2.6.9:
- dependencies:
- whatwg-url: 5.0.0
-
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
- node-gyp-build@4.8.4: {}
-
node-int64@0.4.0: {}
node-releases@2.0.19: {}
- nopt@5.0.0:
- dependencies:
- abbrev: 1.1.1
-
normalize-path@3.0.0: {}
normalize-range@0.1.2: {}
@@ -13863,19 +12576,12 @@ snapshots:
dependencies:
path-key: 3.1.1
- npmlog@5.0.1:
- dependencies:
- are-we-there-yet: 2.0.0
- console-control-strings: 1.1.0
- gauge: 3.0.2
- set-blocking: 2.0.0
-
- nuqs@2.4.3(next@14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1):
+ nuqs@2.4.3(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1):
dependencies:
mitt: 3.0.1
react: 18.3.1
optionalDependencies:
- next: 14.2.31(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ next: 14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
oauth@0.9.15: {}
@@ -13927,10 +12633,6 @@ snapshots:
oidc-token-hash@5.1.1: {}
- once@1.3.3:
- dependencies:
- wrappy: 1.0.2
-
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -13977,16 +12679,12 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
- os-paths@4.4.0: {}
-
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
object-keys: 1.1.1
safe-push-apply: 1.0.0
- p-finally@2.0.1: {}
-
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@@ -14034,21 +12732,12 @@ snapshots:
index-to-position: 1.1.0
type-fest: 4.41.0
- parse-ms@2.1.0: {}
-
- path-browserify@1.0.1: {}
-
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
- path-match@1.2.4:
- dependencies:
- http-errors: 1.4.0
- path-to-regexp: 1.9.0
-
path-parse@1.0.7: {}
path-scurry@1.11.1:
@@ -14056,22 +12745,10 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
- path-to-regexp@1.9.0:
- dependencies:
- isarray: 0.0.1
-
- path-to-regexp@6.1.0: {}
-
- path-to-regexp@6.2.1: {}
-
path-type@4.0.0: {}
- pend@1.2.0: {}
-
perfect-freehand@1.2.2: {}
- picocolors@1.0.0: {}
-
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -14102,13 +12779,13 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.5.6
- postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)):
dependencies:
lilconfig: 3.1.3
yaml: 2.8.1
optionalDependencies:
postcss: 8.5.6
- ts-node: 10.9.1(@types/node@16.18.11)(typescript@5.9.2)
+ ts-node: 10.9.1(@types/node@24.2.1)(typescript@5.9.2)
postcss-nested@6.2.0(postcss@8.5.6):
dependencies:
@@ -14153,14 +12830,8 @@ snapshots:
ansi-styles: 5.2.0
react-is: 18.3.1
- pretty-ms@7.0.1:
- dependencies:
- parse-ms: 2.1.0
-
progress@2.0.3: {}
- promisepipe@3.0.0: {}
-
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -14177,11 +12848,6 @@ snapshots:
dependencies:
proxy-compare: 3.0.1
- pump@3.0.3:
- dependencies:
- end-of-stream: 1.4.5
- once: 1.4.0
-
punycode@2.3.1: {}
pure-rand@7.0.1: {}
@@ -14194,13 +12860,6 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
- raw-body@2.4.1:
- dependencies:
- bytes: 3.1.0
- http-errors: 1.7.3
- iconv-lite: 0.4.24
- unpipe: 1.0.0
-
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
@@ -14292,10 +12951,6 @@ snapshots:
string_decoder: 1.3.0
util-deprecate: 1.0.2
- readdirp@3.3.0:
- dependencies:
- picomatch: 2.3.1
-
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
@@ -14393,11 +13048,7 @@ snapshots:
reusify@1.1.0: {}
- rimraf@3.0.2:
- dependencies:
- glob: 7.2.3
-
- rollup@2.79.2:
+ rollup@2.78.0:
optionalDependencies:
fsevents: 2.3.3
@@ -14431,8 +13082,6 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
- safer-buffer@2.1.2: {}
-
sass@1.90.0:
dependencies:
chokidar: 4.0.3
@@ -14445,19 +13094,26 @@ snapshots:
dependencies:
loose-envify: 1.4.0
+ schema-utils@4.3.2:
+ dependencies:
+ "@types/json-schema": 7.0.15
+ ajv: 8.17.1
+ ajv-formats: 2.1.1(ajv@8.17.1)
+ ajv-keywords: 5.1.0(ajv@8.17.1)
+ optional: true
+
sdp-transform@2.15.0: {}
sdp@3.2.1: {}
semver@6.3.1: {}
- semver@7.3.5:
- dependencies:
- lru-cache: 6.0.0
-
semver@7.7.2: {}
- set-blocking@2.0.0: {}
+ serialize-javascript@6.0.2:
+ dependencies:
+ randombytes: 2.1.0
+ optional: true
set-function-length@1.2.2:
dependencies:
@@ -14481,8 +13137,6 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
- setprototypeof@1.1.1: {}
-
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -14519,8 +13173,6 @@ snapshots:
signal-exit@3.0.7: {}
- signal-exit@4.0.2: {}
-
signal-exit@4.1.0: {}
simple-peer@9.11.1:
@@ -14562,6 +13214,12 @@ snapshots:
buffer-from: 1.1.2
source-map: 0.6.1
+ source-map-support@0.5.21:
+ dependencies:
+ buffer-from: 1.1.2
+ source-map: 0.6.1
+ optional: true
+
source-map@0.5.7: {}
source-map@0.6.1: {}
@@ -14584,25 +13242,11 @@ snapshots:
standard-as-callback@2.1.0: {}
- stat-mode@0.3.0: {}
-
- statuses@1.5.0: {}
-
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
internal-slot: 1.1.0
- stream-to-array@2.3.0:
- dependencies:
- any-promise: 1.3.0
-
- stream-to-promise@2.2.0:
- dependencies:
- any-promise: 1.3.0
- end-of-stream: 1.1.0
- stream-to-array: 2.3.0
-
streamsearch@1.1.0: {}
string-length@4.0.2:
@@ -14743,7 +13387,7 @@ snapshots:
dependencies:
"@pkgr/core": 0.2.9
- tailwindcss@3.4.17(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)):
+ tailwindcss@3.4.17(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)):
dependencies:
"@alloc/quick-lru": 5.2.0
arg: 5.0.2
@@ -14762,7 +13406,7 @@ snapshots:
postcss: 8.5.6
postcss-import: 15.1.0(postcss@8.5.6)
postcss-js: 4.0.1(postcss@8.5.6)
- postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
postcss-nested: 6.2.0(postcss@8.5.6)
postcss-selector-parser: 6.1.2
resolve: 1.22.10
@@ -14770,24 +13414,26 @@ snapshots:
transitivePeerDependencies:
- ts-node
- tar@4.4.18:
- dependencies:
- chownr: 1.1.4
- fs-minipass: 1.2.7
- minipass: 2.9.0
- minizlib: 1.3.3
- mkdirp: 0.5.6
- safe-buffer: 5.2.1
- yallist: 3.1.1
+ tapable@2.2.3:
+ optional: true
- tar@6.2.1:
+ terser-webpack-plugin@5.3.14(webpack@5.101.3):
dependencies:
- chownr: 2.0.0
- fs-minipass: 2.1.0
- minipass: 5.0.0
- minizlib: 2.1.2
- mkdirp: 1.0.4
- yallist: 4.0.0
+ "@jridgewell/trace-mapping": 0.3.30
+ jest-worker: 27.5.1
+ schema-utils: 4.3.2
+ serialize-javascript: 6.0.2
+ terser: 5.44.0
+ webpack: 5.101.3
+ optional: true
+
+ terser@5.44.0:
+ dependencies:
+ "@jridgewell/source-map": 0.3.11
+ acorn: 8.15.0
+ commander: 2.20.3
+ source-map-support: 0.5.21
+ optional: true
test-exclude@6.0.0:
dependencies:
@@ -14803,10 +13449,6 @@ snapshots:
dependencies:
any-promise: 1.3.0
- time-span@4.0.0:
- dependencies:
- convert-hrtime: 3.0.0
-
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.6(picomatch@4.0.3)
@@ -14818,12 +13460,8 @@ snapshots:
dependencies:
is-number: 7.0.0
- toidentifier@1.0.0: {}
-
tr46@0.0.3: {}
- tree-kill@1.2.2: {}
-
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -14834,12 +13472,12 @@ snapshots:
ts-interface-checker@0.1.13: {}
- ts-jest@29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2)))(typescript@5.9.2):
+ ts-jest@29.4.1(@babel/core@7.28.3)(@jest/transform@30.1.2)(@jest/types@30.0.5)(babel-jest@30.1.2(@babel/core@7.28.3))(jest-util@30.0.5)(jest@30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2)))(typescript@5.9.2):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 30.1.3(@types/node@16.18.11)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2))
+ jest: 30.1.3(@types/node@24.2.1)(babel-plugin-macros@3.1.0)(ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -14854,37 +13492,14 @@ snapshots:
babel-jest: 30.1.2(@babel/core@7.28.3)
jest-util: 30.0.5
- ts-morph@12.0.0:
- dependencies:
- "@ts-morph/common": 0.11.1
- code-block-writer: 10.1.1
-
- ts-node@10.9.1(@types/node@16.18.11)(typescript@4.9.5):
+ ts-node@10.9.1(@types/node@24.2.1)(typescript@5.9.2):
dependencies:
"@cspotcode/source-map-support": 0.8.1
"@tsconfig/node10": 1.0.11
"@tsconfig/node12": 1.0.11
"@tsconfig/node14": 1.0.3
"@tsconfig/node16": 1.0.4
- "@types/node": 16.18.11
- acorn: 8.15.0
- acorn-walk: 8.3.4
- arg: 4.1.3
- create-require: 1.1.1
- diff: 4.0.2
- make-error: 1.3.6
- typescript: 4.9.5
- v8-compile-cache-lib: 3.0.1
- yn: 3.1.1
-
- ts-node@10.9.1(@types/node@16.18.11)(typescript@5.9.2):
- dependencies:
- "@cspotcode/source-map-support": 0.8.1
- "@tsconfig/node10": 1.0.11
- "@tsconfig/node12": 1.0.11
- "@tsconfig/node14": 1.0.3
- "@tsconfig/node16": 1.0.4
- "@types/node": 16.18.11
+ "@types/node": 24.2.1
acorn: 8.15.0
acorn-walk: 8.3.4
arg: 4.1.3
@@ -14896,8 +13511,6 @@ snapshots:
yn: 3.1.1
optional: true
- ts-toolbelt@6.15.5: {}
-
tsconfig-paths@3.15.0:
dependencies:
"@types/json5": 0.0.29
@@ -14952,8 +13565,6 @@ snapshots:
possible-typed-array-names: 1.1.0
reflect.getprototypeof: 1.0.10
- typescript@4.9.5: {}
-
typescript@5.9.2: {}
ua-is-frozen@0.1.2: {}
@@ -14983,8 +13594,6 @@ snapshots:
uhyphen@0.1.0: {}
- uid-promise@1.0.0: {}
-
umap@1.0.2: {}
unbox-primitive@1.1.0:
@@ -14996,10 +13605,6 @@ snapshots:
undici-types@7.10.0: {}
- undici@5.28.4:
- dependencies:
- "@fastify/busboy": 2.1.1
-
unified@11.0.5:
dependencies:
"@types/unist": 3.0.3
@@ -15033,12 +13638,6 @@ snapshots:
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
- universalify@0.1.2: {}
-
- universalify@2.0.1: {}
-
- unpipe@1.0.0: {}
-
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.3
@@ -15096,8 +13695,6 @@ snapshots:
uuid-validate@0.0.3: {}
- uuid@3.3.2: {}
-
uuid@8.3.2: {}
uuid@9.0.1: {}
@@ -15106,7 +13703,8 @@ snapshots:
dependencies:
uarray: 1.0.0
- v8-compile-cache-lib@3.0.1: {}
+ v8-compile-cache-lib@3.0.1:
+ optional: true
v8-to-istanbul@9.3.0:
dependencies:
@@ -15114,26 +13712,6 @@ snapshots:
"@types/istanbul-lib-coverage": 2.0.6
convert-source-map: 2.0.0
- vercel@37.14.0:
- dependencies:
- "@vercel/build-utils": 8.4.12
- "@vercel/fun": 1.1.0
- "@vercel/go": 3.2.0
- "@vercel/hydrogen": 1.0.9
- "@vercel/next": 4.3.18
- "@vercel/node": 3.2.24
- "@vercel/python": 4.3.1
- "@vercel/redwood": 2.1.8
- "@vercel/remix-builder": 2.2.13
- "@vercel/ruby": 2.1.0
- "@vercel/static-build": 2.5.34
- chokidar: 3.3.1
- transitivePeerDependencies:
- - "@swc/core"
- - "@swc/wasm"
- - encoding
- - supports-color
-
vfile-message@4.0.3:
dependencies:
"@types/unist": 3.0.3
@@ -15148,14 +13726,51 @@ snapshots:
dependencies:
makeerror: 1.0.12
- wavesurfer.js@7.10.1: {}
+ watchpack@2.4.4:
+ dependencies:
+ glob-to-regexp: 0.4.1
+ graceful-fs: 4.2.11
+ optional: true
- web-vitals@0.2.4: {}
+ wavesurfer.js@7.10.1: {}
webidl-conversions@3.0.1: {}
webpack-sources@3.3.3: {}
+ webpack@5.101.3:
+ dependencies:
+ "@types/eslint-scope": 3.7.7
+ "@types/estree": 1.0.8
+ "@types/json-schema": 7.0.15
+ "@webassemblyjs/ast": 1.14.1
+ "@webassemblyjs/wasm-edit": 1.14.1
+ "@webassemblyjs/wasm-parser": 1.14.1
+ acorn: 8.15.0
+ acorn-import-phases: 1.0.4(acorn@8.15.0)
+ browserslist: 4.25.2
+ chrome-trace-event: 1.0.4
+ enhanced-resolve: 5.18.3
+ es-module-lexer: 1.7.0
+ eslint-scope: 5.1.1
+ events: 3.3.0
+ glob-to-regexp: 0.4.1
+ graceful-fs: 4.2.11
+ json-parse-even-better-errors: 2.3.1
+ loader-runner: 4.3.0
+ mime-types: 2.1.35
+ neo-async: 2.6.2
+ schema-utils: 4.3.2
+ tapable: 2.2.3
+ terser-webpack-plugin: 5.3.14(webpack@5.101.3)
+ watchpack: 2.4.4
+ webpack-sources: 3.3.3
+ transitivePeerDependencies:
+ - "@swc/core"
+ - esbuild
+ - uglify-js
+ optional: true
+
webrtc-adapter@9.0.3:
dependencies:
sdp: 3.2.1
@@ -15210,10 +13825,6 @@ snapshots:
dependencies:
isexe: 2.0.0
- wide-align@1.1.5:
- dependencies:
- string-width: 4.2.3
-
word-wrap@1.2.5: {}
wordwrap@1.0.0: {}
@@ -15239,14 +13850,6 @@ snapshots:
ws@8.17.1: {}
- xdg-app-paths@5.1.0:
- dependencies:
- xdg-portable: 7.3.0
-
- xdg-portable@7.3.0:
- dependencies:
- os-paths: 4.4.0
-
xmlhttprequest-ssl@2.0.0: {}
y18n@5.0.8: {}
@@ -15273,21 +13876,8 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
- yauzl-clone@1.0.4:
- dependencies:
- events-intercept: 2.0.0
-
- yauzl-promise@2.1.3:
- dependencies:
- yauzl: 2.10.0
- yauzl-clone: 1.0.4
-
- yauzl@2.10.0:
- dependencies:
- buffer-crc32: 0.2.13
- fd-slicer: 1.1.0
-
- yn@3.1.1: {}
+ yn@3.1.1:
+ optional: true
yocto-queue@0.1.0: {}
From b3a8e9739da1aa423646b682d80265ffffd6dd2a Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Thu, 11 Sep 2025 17:52:34 -0400
Subject: [PATCH 26/77] chore: whereby & s3 settings env error reporting (#637)
Co-authored-by: Igor Loskutov
---
server/reflector/settings.py | 4 +-
server/reflector/utils/string.py | 7 +++-
server/reflector/whereby.py | 65 +++++++++++++++++++++++++++-----
3 files changed, 63 insertions(+), 13 deletions(-)
diff --git a/server/reflector/settings.py b/server/reflector/settings.py
index 686f67c1..9659f648 100644
--- a/server/reflector/settings.py
+++ b/server/reflector/settings.py
@@ -1,6 +1,8 @@
from pydantic.types import PositiveInt
from pydantic_settings import BaseSettings, SettingsConfigDict
+from reflector.utils.string import NonEmptyString
+
class Settings(BaseSettings):
model_config = SettingsConfigDict(
@@ -120,7 +122,7 @@ class Settings(BaseSettings):
# Whereby integration
WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
- WHEREBY_API_KEY: str | None = None
+ WHEREBY_API_KEY: NonEmptyString | None = None
WHEREBY_WEBHOOK_SECRET: str | None = None
AWS_WHEREBY_ACCESS_KEY_ID: str | None = None
AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None
diff --git a/server/reflector/utils/string.py b/server/reflector/utils/string.py
index 08a9de78..05f40e30 100644
--- a/server/reflector/utils/string.py
+++ b/server/reflector/utils/string.py
@@ -10,8 +10,11 @@ NonEmptyString = Annotated[
non_empty_string_adapter = TypeAdapter(NonEmptyString)
-def parse_non_empty_string(s: str) -> NonEmptyString:
- return non_empty_string_adapter.validate_python(s)
+def parse_non_empty_string(s: str, error: str | None = None) -> NonEmptyString:
+ try:
+ return non_empty_string_adapter.validate_python(s)
+ except Exception as e:
+ raise ValueError(f"{e}: {error}" if error else e) from e
def try_parse_non_empty_string(s: str) -> NonEmptyString | None:
diff --git a/server/reflector/whereby.py b/server/reflector/whereby.py
index deaa5274..8b5c18fd 100644
--- a/server/reflector/whereby.py
+++ b/server/reflector/whereby.py
@@ -1,18 +1,60 @@
+import logging
from datetime import datetime
import httpx
from reflector.db.rooms import Room
from reflector.settings import settings
+from reflector.utils.string import parse_non_empty_string
+
+logger = logging.getLogger(__name__)
+
+
+def _get_headers():
+ api_key = parse_non_empty_string(
+ settings.WHEREBY_API_KEY, "WHEREBY_API_KEY value is required."
+ )
+ return {
+ "Content-Type": "application/json; charset=utf-8",
+ "Authorization": f"Bearer {api_key}",
+ }
+
-HEADERS = {
- "Content-Type": "application/json; charset=utf-8",
- "Authorization": f"Bearer {settings.WHEREBY_API_KEY}",
-}
TIMEOUT = 10 # seconds
+def _get_whereby_s3_auth():
+ errors = []
+ try:
+ bucket_name = parse_non_empty_string(
+ settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
+ "RECORDING_STORAGE_AWS_BUCKET_NAME value is required.",
+ )
+ except Exception as e:
+ errors.append(e)
+ try:
+ key_id = parse_non_empty_string(
+ settings.AWS_WHEREBY_ACCESS_KEY_ID,
+ "AWS_WHEREBY_ACCESS_KEY_ID value is required.",
+ )
+ except Exception as e:
+ errors.append(e)
+ try:
+ key_secret = parse_non_empty_string(
+ settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
+ "AWS_WHEREBY_ACCESS_KEY_SECRET value is required.",
+ )
+ except Exception as e:
+ errors.append(e)
+ if len(errors) > 0:
+ raise Exception(
+ f"Failed to get Whereby auth settings: {', '.join(str(e) for e in errors)}"
+ )
+ return bucket_name, key_id, key_secret
+
+
async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
+ s3_bucket_name, s3_key_id, s3_key_secret = _get_whereby_s3_auth()
data = {
"isLocked": room.is_locked,
"roomNamePrefix": room_name_prefix,
@@ -23,23 +65,26 @@ async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
"type": room.recording_type,
"destination": {
"provider": "s3",
- "bucket": settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
- "accessKeyId": settings.AWS_WHEREBY_ACCESS_KEY_ID,
- "accessKeySecret": settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
+ "bucket": s3_bucket_name,
+ "accessKeyId": s3_key_id,
+ "accessKeySecret": s3_key_secret,
"fileFormat": "mp4",
},
"startTrigger": room.recording_trigger,
},
"fields": ["hostRoomUrl"],
}
-
async with httpx.AsyncClient() as client:
response = await client.post(
f"{settings.WHEREBY_API_URL}/meetings",
- headers=HEADERS,
+ headers=_get_headers(),
json=data,
timeout=TIMEOUT,
)
+ if response.status_code == 403:
+ logger.warning(
+ f"Failed to create meeting: access denied on Whereby: {response.text}"
+ )
response.raise_for_status()
return response.json()
@@ -48,7 +93,7 @@ async def get_room_sessions(room_name: str):
async with httpx.AsyncClient() as client:
response = await client.get(
f"{settings.WHEREBY_API_URL}/insights/room-sessions?roomName={room_name}",
- headers=HEADERS,
+ headers=_get_headers(),
timeout=TIMEOUT,
)
response.raise_for_status()
From 43ea9349f51cfb83edd9e5c70990d7938350375f Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Thu, 11 Sep 2025 20:57:19 -0600
Subject: [PATCH 27/77] chore(main): release 0.10.0 (#616)
---
CHANGELOG.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 987a6579..40174fc4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11)
+
+
+### Features
+
+* replace nextjs-config with environment variables ([#632](https://github.com/Monadical-SAS/reflector/issues/632)) ([369ecdf](https://github.com/Monadical-SAS/reflector/commit/369ecdff13f3862d926a9c0b87df52c9d94c4dde))
+
+
+### Bug Fixes
+
+* anonymous users transcript permissions ([#621](https://github.com/Monadical-SAS/reflector/issues/621)) ([f81fe99](https://github.com/Monadical-SAS/reflector/commit/f81fe9948a9237b3e0001b2d8ca84f54d76878f9))
+* auth post ([#624](https://github.com/Monadical-SAS/reflector/issues/624)) ([cde99ca](https://github.com/Monadical-SAS/reflector/commit/cde99ca2716f84ba26798f289047732f0448742e))
+* auth post ([#626](https://github.com/Monadical-SAS/reflector/issues/626)) ([3b85ff3](https://github.com/Monadical-SAS/reflector/commit/3b85ff3bdf4fb053b103070646811bc990c0e70a))
+* auth post ([#627](https://github.com/Monadical-SAS/reflector/issues/627)) ([962038e](https://github.com/Monadical-SAS/reflector/commit/962038ee3f2a555dc3c03856be0e4409456e0996))
+* missing follow_redirects=True on modal endpoint ([#630](https://github.com/Monadical-SAS/reflector/issues/630)) ([fc363bd](https://github.com/Monadical-SAS/reflector/commit/fc363bd49b17b075e64f9186e5e0185abc325ea7))
+* sync backend and frontend token refresh logic ([#614](https://github.com/Monadical-SAS/reflector/issues/614)) ([5a5b323](https://github.com/Monadical-SAS/reflector/commit/5a5b3233820df9536da75e87ce6184a983d4713a))
+
## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06)
From 5cba5d310d14168f99312acb149603e01c50e953 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Fri, 12 Sep 2025 12:41:44 -0400
Subject: [PATCH 28/77] chore: sentry and nextjs major bumps (#633)
* chore: remove nextjs-config
* build fix
* sentry update
* nextjs update
* feature flags doc
* update readme
* explicit nextjs env vars + remove feature-unrelated things and obsolete vars from config
* full config removal
* remove force-dynamic from pages
* compile fix
* restore claude-deleted tests
* no sentry backward compat
* better .env.example
* AUTHENTIK_REFRESH_TOKEN_URL not so required
* accommodate auth system to requiredLogin feature
---------
Co-authored-by: Igor Loskutov
---
.../[transcriptId]/correct/page.tsx | 14 +-
.../(app)/transcripts/[transcriptId]/page.tsx | 11 +-
.../[transcriptId]/record/page.tsx | 19 +-
.../[transcriptId]/upload/page.tsx | 17 +-
www/app/[roomName]/page.tsx | 8 +-
www/app/api/auth/[...nextauth]/route.ts | 2 +-
www/app/layout.tsx | 8 +-
www/app/lib/AuthProvider.tsx | 115 +-
www/app/lib/apiClient.tsx | 5 +-
www/app/lib/array.ts | 12 +
www/app/lib/authBackend.ts | 211 +-
www/app/lib/errorUtils.ts | 42 +-
www/app/lib/features.ts | 8 +-
www/app/webinars/[title]/page.tsx | 9 +-
...nt.config.ts => instrumentation-client.ts} | 2 +
www/instrumentation.ts | 9 +
www/next.config.js | 1 -
www/package.json | 6 +-
www/pnpm-lock.yaml | 2379 ++++++++++++++---
19 files changed, 2285 insertions(+), 593 deletions(-)
create mode 100644 www/app/lib/array.ts
rename www/{sentry.client.config.ts => instrumentation-client.ts} (91%)
create mode 100644 www/instrumentation.ts
diff --git a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
index 1c7705f4..c4d5a9fc 100644
--- a/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/correct/page.tsx
@@ -1,5 +1,5 @@
"use client";
-import { useState } from "react";
+import { useState, use } from "react";
import TopicHeader from "./topicHeader";
import TopicWords from "./topicWords";
import TopicPlayer from "./topicPlayer";
@@ -18,14 +18,16 @@ import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react";
export type TranscriptCorrect = {
- params: {
+ params: Promise<{
transcriptId: string;
- };
+ }>;
};
-export default function TranscriptCorrect({
- params: { transcriptId },
-}: TranscriptCorrect) {
+export default function TranscriptCorrect(props: TranscriptCorrect) {
+ const params = use(props.params);
+
+ const { transcriptId } = params;
+
const updateTranscriptMutation = useTranscriptUpdate();
const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState();
diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx
index 73bf2ae7..f06e8935 100644
--- a/www/app/(app)/transcripts/[transcriptId]/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx
@@ -5,7 +5,7 @@ import useWaveform from "../useWaveform";
import useMp3 from "../useMp3";
import { TopicList } from "./_components/TopicList";
import { Topic } from "../webSocketTypes";
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useState, use } from "react";
import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
@@ -15,13 +15,14 @@ import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
type TranscriptDetails = {
- params: {
+ params: Promise<{
transcriptId: string;
- };
+ }>;
};
export default function TranscriptDetails(details: TranscriptDetails) {
- const transcriptId = details.params.transcriptId;
+ const params = use(details.params);
+ const transcriptId = params.transcriptId;
const router = useRouter();
const statusToRedirect = [
"idle",
@@ -43,7 +44,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useEffect(() => {
if (waiting) {
- const newUrl = "/transcripts/" + details.params.transcriptId + "/record";
+ const newUrl = "/transcripts/" + params.transcriptId + "/record";
// Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540
diff --git a/www/app/(app)/transcripts/[transcriptId]/record/page.tsx b/www/app/(app)/transcripts/[transcriptId]/record/page.tsx
index 0dc26c6d..d93b34b6 100644
--- a/www/app/(app)/transcripts/[transcriptId]/record/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/record/page.tsx
@@ -1,5 +1,5 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, use } from "react";
import Recorder from "../../recorder";
import { TopicList } from "../_components/TopicList";
import { useWebSockets } from "../../useWebSockets";
@@ -14,19 +14,20 @@ import { useTranscriptGet } from "../../../../lib/apiHooks";
import { TranscriptStatus } from "../../../../lib/transcript";
type TranscriptDetails = {
- params: {
+ params: Promise<{
transcriptId: string;
- };
+ }>;
};
const TranscriptRecord = (details: TranscriptDetails) => {
- const transcript = useTranscriptGet(details.params.transcriptId);
+ const params = use(details.params);
+ const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState(null);
- const webSockets = useWebSockets(details.params.transcriptId);
+ const webSockets = useWebSockets(params.transcriptId);
- const mp3 = useMp3(details.params.transcriptId, true);
+ const mp3 = useMp3(params.transcriptId, true);
const router = useRouter();
@@ -47,7 +48,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
- const newUrl = "/transcripts/" + details.params.transcriptId;
+ const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl);
}
}, [webSockets.status?.value, transcript.data?.status]);
@@ -75,7 +76,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
) : (
// todo: only start recording animation when you get "recorded" status
-
+
)}
{
topics={webSockets.topics}
useActiveTopic={useActiveTopic}
autoscroll={true}
- transcriptId={details.params.transcriptId}
+ transcriptId={params.transcriptId}
status={status}
currentTranscriptText={webSockets.accumulatedText}
/>
diff --git a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
index 844d05e9..b4bc25cc 100644
--- a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
@@ -1,5 +1,5 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, use } from "react";
import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
@@ -9,18 +9,19 @@ import FileUploadButton from "../../fileUploadButton";
import { useTranscriptGet } from "../../../../lib/apiHooks";
type TranscriptUpload = {
- params: {
+ params: Promise<{
transcriptId: string;
- };
+ }>;
};
const TranscriptUpload = (details: TranscriptUpload) => {
- const transcript = useTranscriptGet(details.params.transcriptId);
+ const params = use(details.params);
+ const transcript = useTranscriptGet(params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false);
- const webSockets = useWebSockets(details.params.transcriptId);
+ const webSockets = useWebSockets(params.transcriptId);
- const mp3 = useMp3(details.params.transcriptId, true);
+ const mp3 = useMp3(params.transcriptId, true);
const router = useRouter();
@@ -50,7 +51,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting");
- const newUrl = "/transcripts/" + details.params.transcriptId;
+ const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl);
}
}, [webSockets.status?.value, transcript.data?.status]);
@@ -84,7 +85,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
Please select the file, supported formats: .mp3, m4a, .wav,
.mp4, .mov or .webm
-
+
>
)}
{status && status == "uploaded" && (
diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx
index 0130588b..867aeb3e 100644
--- a/www/app/[roomName]/page.tsx
+++ b/www/app/[roomName]/page.tsx
@@ -7,6 +7,7 @@ import {
useState,
useContext,
RefObject,
+ use,
} from "react";
import {
Box,
@@ -30,9 +31,9 @@ import { FaBars } from "react-icons/fa6";
import { useAuth } from "../lib/AuthProvider";
export type RoomDetails = {
- params: {
+ params: Promise<{
roomName: string;
- };
+ }>;
};
// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
@@ -255,9 +256,10 @@ const useWhereby = () => {
};
export default function Room(details: RoomDetails) {
+ const params = use(details.params);
const wherebyLoaded = useWhereby();
const wherebyRef = useRef
(null);
- const roomName = details.params.roomName;
+ const roomName = params.roomName;
const meeting = useRoomMeeting(roomName);
const router = useRouter();
const status = useAuth().status;
diff --git a/www/app/api/auth/[...nextauth]/route.ts b/www/app/api/auth/[...nextauth]/route.ts
index 7b73c22a..250e9e34 100644
--- a/www/app/api/auth/[...nextauth]/route.ts
+++ b/www/app/api/auth/[...nextauth]/route.ts
@@ -1,6 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "../../../lib/authBackend";
-const handler = NextAuth(authOptions);
+const handler = NextAuth(authOptions());
export { handler as GET, handler as POST };
diff --git a/www/app/layout.tsx b/www/app/layout.tsx
index 93fb15e9..175b7cbc 100644
--- a/www/app/layout.tsx
+++ b/www/app/layout.tsx
@@ -6,6 +6,7 @@ import ErrorMessage from "./(errors)/errorMessage";
import { RecordingConsentProvider } from "./recordingConsentContext";
import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers";
+import { assertExistsAndNonEmptyString } from "./lib/utils";
const poppins = Poppins({
subsets: ["latin"],
@@ -20,8 +21,13 @@ export const viewport: Viewport = {
maximumScale: 1,
};
+const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString(
+ process.env.NEXT_PUBLIC_SITE_URL,
+ "NEXT_PUBLIC_SITE_URL required",
+);
+
export const metadata: Metadata = {
- metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL!),
+ metadataBase: new URL(NEXT_PUBLIC_SITE_URL),
title: {
template: "%s – Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
diff --git a/www/app/lib/AuthProvider.tsx b/www/app/lib/AuthProvider.tsx
index 1a8ebea6..e1eabf99 100644
--- a/www/app/lib/AuthProvider.tsx
+++ b/www/app/lib/AuthProvider.tsx
@@ -9,6 +9,7 @@ import { Session } from "next-auth";
import { SessionAutoRefresh } from "./SessionAutoRefresh";
import { REFRESH_ACCESS_TOKEN_ERROR } from "./auth";
import { assertExists } from "./utils";
+import { featureEnabled } from "./features";
type AuthContextType = (
| { status: "loading" }
@@ -27,65 +28,83 @@ type AuthContextType = (
};
const AuthContext = createContext(undefined);
+const isAuthEnabled = featureEnabled("requireLogin");
+
+const noopAuthContext: AuthContextType = {
+ status: "unauthenticated",
+ update: async () => {
+ return null;
+ },
+ signIn: async () => {
+ throw new Error("signIn not supposed to be called");
+ },
+ signOut: async () => {
+ throw new Error("signOut not supposed to be called");
+ },
+};
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: session, status, update } = useNextAuthSession();
- const customSession = session ? assertCustomSession(session) : null;
- const contextValue: AuthContextType = {
- ...(() => {
- switch (status) {
- case "loading": {
- const sessionIsHere = !!customSession;
- switch (sessionIsHere) {
- case false: {
- return { status };
+ const contextValue: AuthContextType = isAuthEnabled
+ ? {
+ ...(() => {
+ switch (status) {
+ case "loading": {
+ const sessionIsHere = !!session;
+ // actually exists sometimes; nextAuth types are something else
+ switch (sessionIsHere as boolean) {
+ case false: {
+ return { status };
+ }
+ case true: {
+ return {
+ status: "refreshing" as const,
+ user: assertCustomSession(
+ assertExists(session as unknown as Session),
+ ).user,
+ };
+ }
+ default: {
+ throw new Error("unreachable");
+ }
+ }
}
- case true: {
- return {
- status: "refreshing" as const,
- user: assertExists(customSession).user,
- };
+ case "authenticated": {
+ const customSession = assertCustomSession(session);
+ if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
+ // token had expired but next auth still returns "authenticated" so show user unauthenticated state
+ return {
+ status: "unauthenticated" as const,
+ };
+ } else if (customSession?.accessToken) {
+ return {
+ status,
+ accessToken: customSession.accessToken,
+ accessTokenExpires: customSession.accessTokenExpires,
+ user: customSession.user,
+ };
+ } else {
+ console.warn(
+ "illegal state: authenticated but have no session/or access token. ignoring",
+ );
+ return { status: "unauthenticated" as const };
+ }
+ }
+ case "unauthenticated": {
+ return { status: "unauthenticated" as const };
}
default: {
- const _: never = sessionIsHere;
+ const _: never = status;
throw new Error("unreachable");
}
}
- }
- case "authenticated": {
- if (customSession?.error === REFRESH_ACCESS_TOKEN_ERROR) {
- // token had expired but next auth still returns "authenticated" so show user unauthenticated state
- return {
- status: "unauthenticated" as const,
- };
- } else if (customSession?.accessToken) {
- return {
- status,
- accessToken: customSession.accessToken,
- accessTokenExpires: customSession.accessTokenExpires,
- user: customSession.user,
- };
- } else {
- console.warn(
- "illegal state: authenticated but have no session/or access token. ignoring",
- );
- return { status: "unauthenticated" as const };
- }
- }
- case "unauthenticated": {
- return { status: "unauthenticated" as const };
- }
- default: {
- const _: never = status;
- throw new Error("unreachable");
- }
+ })(),
+ update,
+ signIn,
+ signOut,
}
- })(),
- update,
- signIn,
- signOut,
- };
+ : noopAuthContext;
// not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
index 95051913..86f8f161 100644
--- a/www/app/lib/apiClient.tsx
+++ b/www/app/lib/apiClient.tsx
@@ -7,7 +7,10 @@ import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
export const API_URL = !isBuildPhase
- ? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
+ ? assertExistsAndNonEmptyString(
+ process.env.NEXT_PUBLIC_API_URL,
+ "NEXT_PUBLIC_API_URL required",
+ )
: "http://localhost";
// TODO decide strict validation or not
diff --git a/www/app/lib/array.ts b/www/app/lib/array.ts
new file mode 100644
index 00000000..f47aaa42
--- /dev/null
+++ b/www/app/lib/array.ts
@@ -0,0 +1,12 @@
+export type NonEmptyArray = [T, ...T[]];
+export const isNonEmptyArray = (arr: T[]): arr is NonEmptyArray =>
+ arr.length > 0;
+export const assertNonEmptyArray = (
+ arr: T[],
+ err?: string,
+): NonEmptyArray => {
+ if (isNonEmptyArray(arr)) {
+ return arr;
+ }
+ throw new Error(err ?? "Expected non-empty array");
+};
diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts
index 06bddff2..5e9767c9 100644
--- a/www/app/lib/authBackend.ts
+++ b/www/app/lib/authBackend.ts
@@ -19,102 +19,126 @@ import {
} from "./redisTokenCache";
import { tokenCacheRedis, redlock } from "./redisClient";
import { isBuildPhase } from "./next";
+import { sequenceThrows } from "./errorUtils";
+import { featureEnabled } from "./features";
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
-const CLIENT_ID = !isBuildPhase
- ? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_ID)
- : "noop";
-const CLIENT_SECRET = !isBuildPhase
- ? assertExistsAndNonEmptyString(process.env.AUTHENTIK_CLIENT_SECRET)
- : "noop";
+const getAuthentikClientId = () =>
+ assertExistsAndNonEmptyString(
+ process.env.AUTHENTIK_CLIENT_ID,
+ "AUTHENTIK_CLIENT_ID required",
+ );
+const getAuthentikClientSecret = () =>
+ assertExistsAndNonEmptyString(
+ process.env.AUTHENTIK_CLIENT_SECRET,
+ "AUTHENTIK_CLIENT_SECRET required",
+ );
+const getAuthentikRefreshTokenUrl = () =>
+ assertExistsAndNonEmptyString(
+ process.env.AUTHENTIK_REFRESH_TOKEN_URL,
+ "AUTHENTIK_REFRESH_TOKEN_URL required",
+ );
-export const authOptions: AuthOptions = {
- providers: [
- AuthentikProvider({
- clientId: CLIENT_ID,
- clientSecret: CLIENT_SECRET,
- issuer: process.env.AUTHENTIK_ISSUER,
- authorization: {
- params: {
- scope: "openid email profile offline_access",
+export const authOptions = (): AuthOptions =>
+ featureEnabled("requireLogin")
+ ? {
+ providers: [
+ AuthentikProvider({
+ ...(() => {
+ const [clientId, clientSecret] = sequenceThrows(
+ getAuthentikClientId,
+ getAuthentikClientSecret,
+ );
+ return {
+ clientId,
+ clientSecret,
+ };
+ })(),
+ issuer: process.env.AUTHENTIK_ISSUER,
+ authorization: {
+ params: {
+ scope: "openid email profile offline_access",
+ },
+ },
+ }),
+ ],
+ session: {
+ strategy: "jwt",
},
- },
- }),
- ],
- session: {
- strategy: "jwt",
- },
- callbacks: {
- async jwt({ token, account, user }) {
- if (account && !account.access_token) {
- await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
- }
+ callbacks: {
+ async jwt({ token, account, user }) {
+ if (account && !account.access_token) {
+ await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
+ }
- if (account && user) {
- // called only on first login
- // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
- if (account.access_token) {
- const expiresAtS = assertExists(account.expires_at);
- const expiresAtMs = expiresAtS * 1000;
- const jwtToken: JWTWithAccessToken = {
- ...token,
- accessToken: account.access_token,
- accessTokenExpires: expiresAtMs,
- refreshToken: account.refresh_token,
- };
- if (jwtToken.error) {
- await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
- } else {
- assertNotExists(
- jwtToken.error,
- `panic! trying to cache token with error in jwt: ${jwtToken.error}`,
+ if (account && user) {
+ // called only on first login
+ // XXX account.expires_in used in example is not defined for authentik backend, but expires_at is
+ if (account.access_token) {
+ const expiresAtS = assertExists(account.expires_at);
+ const expiresAtMs = expiresAtS * 1000;
+ const jwtToken: JWTWithAccessToken = {
+ ...token,
+ accessToken: account.access_token,
+ accessTokenExpires: expiresAtMs,
+ refreshToken: account.refresh_token,
+ };
+ if (jwtToken.error) {
+ await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
+ } else {
+ assertNotExists(
+ jwtToken.error,
+ `panic! trying to cache token with error in jwt: ${jwtToken.error}`,
+ );
+ await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
+ token: jwtToken,
+ timestamp: Date.now(),
+ });
+ return jwtToken;
+ }
+ }
+ }
+
+ const currentToken = await getTokenCache(
+ tokenCacheRedis,
+ `token:${token.sub}`,
);
- await setTokenCache(tokenCacheRedis, `token:${token.sub}`, {
- token: jwtToken,
- timestamp: Date.now(),
- });
- return jwtToken;
- }
- }
- }
+ console.debug(
+ "currentToken from cache",
+ JSON.stringify(currentToken, null, 2),
+ "will be returned?",
+ currentToken &&
+ !shouldRefreshToken(currentToken.token.accessTokenExpires),
+ );
+ if (
+ currentToken &&
+ !shouldRefreshToken(currentToken.token.accessTokenExpires)
+ ) {
+ return currentToken.token;
+ }
- const currentToken = await getTokenCache(
- tokenCacheRedis,
- `token:${token.sub}`,
- );
- console.debug(
- "currentToken from cache",
- JSON.stringify(currentToken, null, 2),
- "will be returned?",
- currentToken &&
- !shouldRefreshToken(currentToken.token.accessTokenExpires),
- );
- if (
- currentToken &&
- !shouldRefreshToken(currentToken.token.accessTokenExpires)
- ) {
- return currentToken.token;
- }
-
- // access token has expired, try to update it
- return await lockedRefreshAccessToken(token);
- },
- async session({ session, token }) {
- const extendedToken = token as JWTWithAccessToken;
- return {
- ...session,
- accessToken: extendedToken.accessToken,
- accessTokenExpires: extendedToken.accessTokenExpires,
- error: extendedToken.error,
- user: {
- id: assertExists(extendedToken.sub),
- name: extendedToken.name,
- email: extendedToken.email,
+ // access token has expired, try to update it
+ return await lockedRefreshAccessToken(token);
+ },
+ async session({ session, token }) {
+ const extendedToken = token as JWTWithAccessToken;
+ return {
+ ...session,
+ accessToken: extendedToken.accessToken,
+ accessTokenExpires: extendedToken.accessTokenExpires,
+ error: extendedToken.error,
+ user: {
+ id: assertExists(extendedToken.sub),
+ name: extendedToken.name,
+ email: extendedToken.email,
+ },
+ } satisfies CustomSession;
+ },
},
- } satisfies CustomSession;
- },
- },
-};
+ }
+ : {
+ providers: [],
+ };
async function lockedRefreshAccessToken(
token: JWT,
@@ -174,16 +198,19 @@ async function lockedRefreshAccessToken(
}
async function refreshAccessToken(token: JWT): Promise {
+ const [url, clientId, clientSecret] = sequenceThrows(
+ getAuthentikRefreshTokenUrl,
+ getAuthentikClientId,
+ getAuthentikClientSecret,
+ );
try {
- const url = `${process.env.AUTHENTIK_REFRESH_TOKEN_URL}`;
-
const options = {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
- client_id: process.env.AUTHENTIK_CLIENT_ID as string,
- client_secret: process.env.AUTHENTIK_CLIENT_SECRET as string,
+ client_id: clientId,
+ client_secret: clientSecret,
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
}).toString(),
diff --git a/www/app/lib/errorUtils.ts b/www/app/lib/errorUtils.ts
index e9e5300d..1512230c 100644
--- a/www/app/lib/errorUtils.ts
+++ b/www/app/lib/errorUtils.ts
@@ -1,4 +1,6 @@
-function shouldShowError(error: Error | null | undefined) {
+import { isNonEmptyArray, NonEmptyArray } from "./array";
+
+export function shouldShowError(error: Error | null | undefined) {
if (
error?.name == "ResponseError" &&
(error["response"].status == 404 || error["response"].status == 403)
@@ -8,4 +10,40 @@ function shouldShowError(error: Error | null | undefined) {
return true;
}
-export { shouldShowError };
+const defaultMergeErrors = (ex: NonEmptyArray): unknown => {
+ try {
+ return new Error(
+ ex
+ .map((e) =>
+ e ? (e.toString ? e.toString() : JSON.stringify(e)) : `${e}`,
+ )
+ .join("\n"),
+ );
+ } catch (e) {
+ console.error("Error merging errors:", e);
+ return ex[0];
+ }
+};
+
+type ReturnTypes any)[]> = {
+ [K in keyof T]: T[K] extends () => infer R ? R : never;
+};
+
+// sequence semantic for "throws"
+// calls functions passed and collects its thrown values
+export function sequenceThrows any)[]>(
+ ...fs: Fns
+): ReturnTypes {
+ const results: unknown[] = [];
+ const errors: unknown[] = [];
+
+ for (const f of fs) {
+ try {
+ results.push(f());
+ } catch (e) {
+ errors.push(e);
+ }
+ }
+ if (errors.length) throw defaultMergeErrors(errors as NonEmptyArray);
+ return results as ReturnTypes;
+}
diff --git a/www/app/lib/features.ts b/www/app/lib/features.ts
index 86452ae7..7684c8e0 100644
--- a/www/app/lib/features.ts
+++ b/www/app/lib/features.ts
@@ -11,11 +11,11 @@ export type FeatureName = (typeof FEATURES)[number];
export type Features = Readonly>;
export const DEFAULT_FEATURES: Features = {
- requireLogin: false,
+ requireLogin: true,
privacy: true,
- browse: false,
- sendToZulip: false,
- rooms: false,
+ browse: true,
+ sendToZulip: true,
+ rooms: true,
} as const;
function parseBooleanEnv(
diff --git a/www/app/webinars/[title]/page.tsx b/www/app/webinars/[title]/page.tsx
index ab873a6b..51583a2a 100644
--- a/www/app/webinars/[title]/page.tsx
+++ b/www/app/webinars/[title]/page.tsx
@@ -1,5 +1,5 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, use } from "react";
import Link from "next/link";
import Image from "next/image";
import { notFound } from "next/navigation";
@@ -30,9 +30,9 @@ const FORM_FIELDS = {
};
export type WebinarDetails = {
- params: {
+ params: Promise<{
title: string;
- };
+ }>;
};
export type Webinar = {
@@ -63,7 +63,8 @@ const WEBINARS: Webinar[] = [
];
export default function WebinarPage(details: WebinarDetails) {
- const title = details.params.title;
+ const params = use(details.params);
+ const title = params.title;
const webinar = WEBINARS.find((webinar) => webinar.title === title);
if (!webinar) {
return notFound();
diff --git a/www/sentry.client.config.ts b/www/instrumentation-client.ts
similarity index 91%
rename from www/sentry.client.config.ts
rename to www/instrumentation-client.ts
index aff65bbd..5ea5e2e9 100644
--- a/www/sentry.client.config.ts
+++ b/www/instrumentation-client.ts
@@ -23,3 +23,5 @@ if (SENTRY_DSN) {
replaysSessionSampleRate: 0.0,
});
}
+
+export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
diff --git a/www/instrumentation.ts b/www/instrumentation.ts
new file mode 100644
index 00000000..f8a929ba
--- /dev/null
+++ b/www/instrumentation.ts
@@ -0,0 +1,9 @@
+export async function register() {
+ if (process.env.NEXT_RUNTIME === "nodejs") {
+ await import("./sentry.server.config");
+ }
+
+ if (process.env.NEXT_RUNTIME === "edge") {
+ await import("./sentry.edge.config");
+ }
+}
diff --git a/www/next.config.js b/www/next.config.js
index bbc3f710..eedbac7f 100644
--- a/www/next.config.js
+++ b/www/next.config.js
@@ -1,7 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
- experimental: { esmExternals: "loose" },
env: {
IS_CI: process.env.IS_CI,
},
diff --git a/www/package.json b/www/package.json
index e55be4f0..d53c1536 100644
--- a/www/package.json
+++ b/www/package.json
@@ -17,19 +17,19 @@
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
- "@sentry/nextjs": "^7.77.0",
+ "@sentry/nextjs": "^10.11.0",
"@tanstack/react-query": "^5.85.9",
"@types/ioredis": "^5.0.0",
"@whereby.com/browser-sdk": "^3.3.4",
"autoprefixer": "10.4.20",
"axios": "^1.8.2",
"eslint": "^9.33.0",
- "eslint-config-next": "^14.2.31",
+ "eslint-config-next": "^15.5.3",
"fontawesome": "^5.6.3",
"ioredis": "^5.7.0",
"jest-worker": "^29.6.2",
"lucide-react": "^0.525.0",
- "next": "^14.2.30",
+ "next": "^15.5.3",
"next-auth": "^4.24.7",
"next-themes": "^0.4.6",
"nuqs": "^2.4.3",
diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml
index cf9351d4..a4e78972 100644
--- a/www/pnpm-lock.yaml
+++ b/www/pnpm-lock.yaml
@@ -23,8 +23,8 @@ importers:
specifier: ^0.2.0
version: 0.2.3(@fortawesome/fontawesome-svg-core@6.7.2)(react@18.3.1)
"@sentry/nextjs":
- specifier: ^7.77.0
- version: 7.77.0(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)
+ specifier: ^10.11.0
+ version: 10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)
"@tanstack/react-query":
specifier: ^5.85.9
version: 5.85.9(react@18.3.1)
@@ -44,8 +44,8 @@ importers:
specifier: ^9.33.0
version: 9.33.0(jiti@1.21.7)
eslint-config-next:
- specifier: ^14.2.31
- version: 14.2.31(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2)
+ specifier: ^15.5.3
+ version: 15.5.3(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2)
fontawesome:
specifier: ^5.6.3
version: 5.6.3
@@ -59,17 +59,17 @@ importers:
specifier: ^0.525.0
version: 0.525.0(react@18.3.1)
next:
- specifier: ^14.2.30
- version: 14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ specifier: ^15.5.3
+ version: 15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
next-auth:
specifier: ^4.24.7
- version: 4.24.11(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ version: 4.24.11(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
nuqs:
specifier: ^2.4.3
- version: 2.4.3(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
+ version: 2.4.3(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)
openapi-fetch:
specifier: ^0.14.0
version: 0.14.0
@@ -745,6 +745,194 @@ packages:
}
engines: { node: ">=18.18" }
+ "@img/sharp-darwin-arm64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [arm64]
+ os: [darwin]
+
+ "@img/sharp-darwin-x64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [x64]
+ os: [darwin]
+
+ "@img/sharp-libvips-darwin-arm64@1.2.0":
+ resolution:
+ {
+ integrity: sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==,
+ }
+ cpu: [arm64]
+ os: [darwin]
+
+ "@img/sharp-libvips-darwin-x64@1.2.0":
+ resolution:
+ {
+ integrity: sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==,
+ }
+ cpu: [x64]
+ os: [darwin]
+
+ "@img/sharp-libvips-linux-arm64@1.2.0":
+ resolution:
+ {
+ integrity: sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==,
+ }
+ cpu: [arm64]
+ os: [linux]
+
+ "@img/sharp-libvips-linux-arm@1.2.0":
+ resolution:
+ {
+ integrity: sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==,
+ }
+ cpu: [arm]
+ os: [linux]
+
+ "@img/sharp-libvips-linux-ppc64@1.2.0":
+ resolution:
+ {
+ integrity: sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==,
+ }
+ cpu: [ppc64]
+ os: [linux]
+
+ "@img/sharp-libvips-linux-s390x@1.2.0":
+ resolution:
+ {
+ integrity: sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==,
+ }
+ cpu: [s390x]
+ os: [linux]
+
+ "@img/sharp-libvips-linux-x64@1.2.0":
+ resolution:
+ {
+ integrity: sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==,
+ }
+ cpu: [x64]
+ os: [linux]
+
+ "@img/sharp-libvips-linuxmusl-arm64@1.2.0":
+ resolution:
+ {
+ integrity: sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==,
+ }
+ cpu: [arm64]
+ os: [linux]
+
+ "@img/sharp-libvips-linuxmusl-x64@1.2.0":
+ resolution:
+ {
+ integrity: sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==,
+ }
+ cpu: [x64]
+ os: [linux]
+
+ "@img/sharp-linux-arm64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [arm64]
+ os: [linux]
+
+ "@img/sharp-linux-arm@0.34.3":
+ resolution:
+ {
+ integrity: sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [arm]
+ os: [linux]
+
+ "@img/sharp-linux-ppc64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [ppc64]
+ os: [linux]
+
+ "@img/sharp-linux-s390x@0.34.3":
+ resolution:
+ {
+ integrity: sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [s390x]
+ os: [linux]
+
+ "@img/sharp-linux-x64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [x64]
+ os: [linux]
+
+ "@img/sharp-linuxmusl-arm64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [arm64]
+ os: [linux]
+
+ "@img/sharp-linuxmusl-x64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [x64]
+ os: [linux]
+
+ "@img/sharp-wasm32@0.34.3":
+ resolution:
+ {
+ integrity: sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [wasm32]
+
+ "@img/sharp-win32-arm64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [arm64]
+ os: [win32]
+
+ "@img/sharp-win32-ia32@0.34.3":
+ resolution:
+ {
+ integrity: sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [ia32]
+ os: [win32]
+
+ "@img/sharp-win32-x64@0.34.3":
+ resolution:
+ {
+ integrity: sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+ cpu: [x64]
+ os: [win32]
+
"@internationalized/date@3.8.2":
resolution:
{
@@ -965,6 +1153,12 @@ packages:
integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==,
}
+ "@jridgewell/trace-mapping@0.3.31":
+ resolution:
+ {
+ integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==,
+ }
+
"@jridgewell/trace-mapping@0.3.9":
resolution:
{
@@ -977,94 +1171,85 @@ packages:
integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==,
}
- "@next/env@14.2.31":
+ "@next/env@15.5.3":
resolution:
{
- integrity: sha512-X8VxxYL6VuezrG82h0pUA1V+DuTSJp7Nv15bxq3ivrFqZLjx81rfeHMWOE9T0jm1n3DtHGv8gdn6B0T0kr0D3Q==,
+ integrity: sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw==,
}
- "@next/eslint-plugin-next@14.2.31":
+ "@next/eslint-plugin-next@15.5.3":
resolution:
{
- integrity: sha512-ouaB+l8Cr/uzGxoGHUvd01OnfFTM8qM81Crw1AG0xoWDRN0DKLXyTWVe0FdAOHVBpGuXB87aufdRmrwzZDArIw==,
+ integrity: sha512-SdhaKdko6dpsSr0DldkESItVrnPYB1NS2NpShCSX5lc7SSQmLZt5Mug6t2xbiuVWEVDLZSuIAoQyYVBYp0dR5g==,
}
- "@next/swc-darwin-arm64@14.2.31":
+ "@next/swc-darwin-arm64@15.5.3":
resolution:
{
- integrity: sha512-dTHKfaFO/xMJ3kzhXYgf64VtV6MMwDs2viedDOdP+ezd0zWMOQZkxcwOfdcQeQCpouTr9b+xOqMCUXxgLizl8Q==,
+ integrity: sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg==,
}
engines: { node: ">= 10" }
cpu: [arm64]
os: [darwin]
- "@next/swc-darwin-x64@14.2.31":
+ "@next/swc-darwin-x64@15.5.3":
resolution:
{
- integrity: sha512-iSavebQgeMukUAfjfW8Fi2Iz01t95yxRl2w2wCzjD91h5In9la99QIDKcKSYPfqLjCgwz3JpIWxLG6LM/sxL4g==,
+ integrity: sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g==,
}
engines: { node: ">= 10" }
cpu: [x64]
os: [darwin]
- "@next/swc-linux-arm64-gnu@14.2.31":
+ "@next/swc-linux-arm64-gnu@15.5.3":
resolution:
{
- integrity: sha512-XJb3/LURg1u1SdQoopG6jDL2otxGKChH2UYnUTcby4izjM0il7ylBY5TIA7myhvHj9lG5pn9F2nR2s3i8X9awQ==,
+ integrity: sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw==,
}
engines: { node: ">= 10" }
cpu: [arm64]
os: [linux]
- "@next/swc-linux-arm64-musl@14.2.31":
+ "@next/swc-linux-arm64-musl@15.5.3":
resolution:
{
- integrity: sha512-IInDAcchNCu3BzocdqdCv1bKCmUVO/bKJHnBFTeq3svfaWpOPewaLJ2Lu3GL4yV76c/86ZvpBbG/JJ1lVIs5MA==,
+ integrity: sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ==,
}
engines: { node: ">= 10" }
cpu: [arm64]
os: [linux]
- "@next/swc-linux-x64-gnu@14.2.31":
+ "@next/swc-linux-x64-gnu@15.5.3":
resolution:
{
- integrity: sha512-YTChJL5/9e4NXPKW+OJzsQa42RiWUNbE+k+ReHvA+lwXk+bvzTsVQboNcezWOuCD+p/J+ntxKOB/81o0MenBhw==,
+ integrity: sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA==,
}
engines: { node: ">= 10" }
cpu: [x64]
os: [linux]
- "@next/swc-linux-x64-musl@14.2.31":
+ "@next/swc-linux-x64-musl@15.5.3":
resolution:
{
- integrity: sha512-A0JmD1y4q/9ufOGEAhoa60Sof++X10PEoiWOH0gZ2isufWZeV03NnyRlRmJpRQWGIbRkJUmBo9I3Qz5C10vx4w==,
+ integrity: sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg==,
}
engines: { node: ">= 10" }
cpu: [x64]
os: [linux]
- "@next/swc-win32-arm64-msvc@14.2.31":
+ "@next/swc-win32-arm64-msvc@15.5.3":
resolution:
{
- integrity: sha512-nowJ5GbMeDOMzbTm29YqrdrD6lTM8qn2wnZfGpYMY7SZODYYpaJHH1FJXE1l1zWICHR+WfIMytlTDBHu10jb8A==,
+ integrity: sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA==,
}
engines: { node: ">= 10" }
cpu: [arm64]
os: [win32]
- "@next/swc-win32-ia32-msvc@14.2.31":
+ "@next/swc-win32-x64-msvc@15.5.3":
resolution:
{
- integrity: sha512-pk9Bu4K0015anTS1OS9d/SpS0UtRObC+xe93fwnm7Gvqbv/W1ZbzhK4nvc96RURIQOux3P/bBH316xz8wjGSsA==,
- }
- engines: { node: ">= 10" }
- cpu: [ia32]
- os: [win32]
-
- "@next/swc-win32-x64-msvc@14.2.31":
- resolution:
- {
- integrity: sha512-LwFZd4JFnMHGceItR9+jtlMm8lGLU/IPkgjBBgYmdYSfalbHCiDpjMYtgDQ2wtwiAOSJOCyFI4m8PikrsDyA6Q==,
+ integrity: sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw==,
}
engines: { node: ">= 10" }
cpu: [x64]
@@ -1098,6 +1283,27 @@ packages:
}
engines: { node: ">=12.4.0" }
+ "@opentelemetry/api-logs@0.203.0":
+ resolution:
+ {
+ integrity: sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ==,
+ }
+ engines: { node: ">=8.0.0" }
+
+ "@opentelemetry/api-logs@0.204.0":
+ resolution:
+ {
+ integrity: sha512-DqxY8yoAaiBPivoJD4UtgrMS8gEmzZ5lnaxzPojzLVHBGqPxgWm4zcuvcUHZiqQ6kRX2Klel2r9y8cA2HAtqpw==,
+ }
+ engines: { node: ">=8.0.0" }
+
+ "@opentelemetry/api-logs@0.57.2":
+ resolution:
+ {
+ integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==,
+ }
+ engines: { node: ">=14" }
+
"@opentelemetry/api@1.9.0":
resolution:
{
@@ -1105,6 +1311,299 @@ packages:
}
engines: { node: ">=8.0.0" }
+ "@opentelemetry/context-async-hooks@2.1.0":
+ resolution:
+ {
+ integrity: sha512-zOyetmZppnwTyPrt4S7jMfXiSX9yyfF0hxlA8B5oo2TtKl+/RGCy7fi4DrBfIf3lCPrkKsRBWZZD7RFojK7FDg==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+
+ "@opentelemetry/core@2.0.1":
+ resolution:
+ {
+ integrity: sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+
+ "@opentelemetry/core@2.1.0":
+ resolution:
+ {
+ integrity: sha512-RMEtHsxJs/GiHHxYT58IY57UXAQTuUnZVco6ymDEqTNlJKTimM4qPUPVe8InNFyBjhHBEAx4k3Q8LtNayBsbUQ==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ">=1.0.0 <1.10.0"
+
+ "@opentelemetry/instrumentation-amqplib@0.50.0":
+ resolution:
+ {
+ integrity: sha512-kwNs/itehHG/qaQBcVrLNcvXVPW0I4FCOVtw3LHMLdYIqD7GJ6Yv2nX+a4YHjzbzIeRYj8iyMp0Bl7tlkidq5w==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-connect@0.47.0":
+ resolution:
+ {
+ integrity: sha512-pjenvjR6+PMRb6/4X85L4OtkQCootgb/Jzh/l/Utu3SJHBid1F+gk9sTGU2FWuhhEfV6P7MZ7BmCdHXQjgJ42g==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-dataloader@0.21.1":
+ resolution:
+ {
+ integrity: sha512-hNAm/bwGawLM8VDjKR0ZUDJ/D/qKR3s6lA5NV+btNaPVm2acqhPcT47l2uCVi+70lng2mywfQncor9v8/ykuyw==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-express@0.52.0":
+ resolution:
+ {
+ integrity: sha512-W7pizN0Wh1/cbNhhTf7C62NpyYw7VfCFTYg0DYieSTrtPBT1vmoSZei19wfKLnrMsz3sHayCg0HxCVL2c+cz5w==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-fs@0.23.0":
+ resolution:
+ {
+ integrity: sha512-Puan+QopWHA/KNYvDfOZN6M/JtF6buXEyD934vrb8WhsX1/FuM7OtoMlQyIqAadnE8FqqDL4KDPiEfCQH6pQcQ==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-generic-pool@0.47.0":
+ resolution:
+ {
+ integrity: sha512-UfHqf3zYK+CwDwEtTjaD12uUqGGTswZ7ofLBEdQ4sEJp9GHSSJMQ2hT3pgBxyKADzUdoxQAv/7NqvL42ZI+Qbw==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-graphql@0.51.0":
+ resolution:
+ {
+ integrity: sha512-LchkOu9X5DrXAnPI1+Z06h/EH/zC7D6sA86hhPrk3evLlsJTz0grPrkL/yUJM9Ty0CL/y2HSvmWQCjbJEz/ADg==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-hapi@0.50.0":
+ resolution:
+ {
+ integrity: sha512-5xGusXOFQXKacrZmDbpHQzqYD1gIkrMWuwvlrEPkYOsjUqGUjl1HbxCsn5Y9bUXOCgP1Lj6A4PcKt1UiJ2MujA==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-http@0.203.0":
+ resolution:
+ {
+ integrity: sha512-y3uQAcCOAwnO6vEuNVocmpVzG3PER6/YZqbPbbffDdJ9te5NkHEkfSMNzlC3+v7KlE+WinPGc3N7MR30G1HY2g==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-ioredis@0.52.0":
+ resolution:
+ {
+ integrity: sha512-rUvlyZwI90HRQPYicxpDGhT8setMrlHKokCtBtZgYxQWRF5RBbG4q0pGtbZvd7kyseuHbFpA3I/5z7M8b/5ywg==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-kafkajs@0.13.0":
+ resolution:
+ {
+ integrity: sha512-FPQyJsREOaGH64hcxlzTsIEQC4DYANgTwHjiB7z9lldmvua1LRMVn3/FfBlzXoqF179B0VGYviz6rn75E9wsDw==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-knex@0.48.0":
+ resolution:
+ {
+ integrity: sha512-V5wuaBPv/lwGxuHjC6Na2JFRjtPgstw19jTFl1B1b6zvaX8zVDYUDaR5hL7glnQtUSCMktPttQsgK4dhXpddcA==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-koa@0.51.0":
+ resolution:
+ {
+ integrity: sha512-XNLWeMTMG1/EkQBbgPYzCeBD0cwOrfnn8ao4hWgLv0fNCFQu1kCsJYygz2cvKuCs340RlnG4i321hX7R8gj3Rg==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-lru-memoizer@0.48.0":
+ resolution:
+ {
+ integrity: sha512-KUW29wfMlTPX1wFz+NNrmE7IzN7NWZDrmFWHM/VJcmFEuQGnnBuTIdsP55CnBDxKgQ/qqYFp4udQFNtjeFosPw==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-mongodb@0.56.0":
+ resolution:
+ {
+ integrity: sha512-YG5IXUUmxX3Md2buVMvxm9NWlKADrnavI36hbJsihqqvBGsWnIfguf0rUP5Srr0pfPqhQjUP+agLMsvu0GmUpA==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-mongoose@0.50.0":
+ resolution:
+ {
+ integrity: sha512-Am8pk1Ct951r4qCiqkBcGmPIgGhoDiFcRtqPSLbJrUZqEPUsigjtMjoWDRLG1Ki1NHgOF7D0H7d+suWz1AAizw==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-mysql2@0.50.0":
+ resolution:
+ {
+ integrity: sha512-PoOMpmq73rOIE3nlTNLf3B1SyNYGsp7QXHYKmeTZZnJ2Ou7/fdURuOhWOI0e6QZ5gSem18IR1sJi6GOULBQJ9g==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-mysql@0.49.0":
+ resolution:
+ {
+ integrity: sha512-QU9IUNqNsrlfE3dJkZnFHqLjlndiU39ll/YAAEvWE40sGOCi9AtOF6rmEGzJ1IswoZ3oyePV7q2MP8SrhJfVAA==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-pg@0.55.0":
+ resolution:
+ {
+ integrity: sha512-yfJ5bYE7CnkW/uNsnrwouG/FR7nmg09zdk2MSs7k0ZOMkDDAE3WBGpVFFApGgNu2U+gtzLgEzOQG4I/X+60hXw==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-redis@0.51.0":
+ resolution:
+ {
+ integrity: sha512-uL/GtBA0u72YPPehwOvthAe+Wf8k3T+XQPBssJmTYl6fzuZjNq8zTfxVFhl9nRFjFVEe+CtiYNT0Q3AyqW1Z0A==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-tedious@0.22.0":
+ resolution:
+ {
+ integrity: sha512-XrrNSUCyEjH1ax9t+Uo6lv0S2FCCykcF7hSxBMxKf7Xn0bPRxD3KyFUZy25aQXzbbbUHhtdxj3r2h88SfEM3aA==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation-undici@0.14.0":
+ resolution:
+ {
+ integrity: sha512-2HN+7ztxAReXuxzrtA3WboAKlfP5OsPA57KQn2AdYZbJ3zeRPcLXyW4uO/jpLE6PLm0QRtmeGCmfYpqRlwgSwg==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.7.0
+
+ "@opentelemetry/instrumentation@0.203.0":
+ resolution:
+ {
+ integrity: sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation@0.204.0":
+ resolution:
+ {
+ integrity: sha512-vV5+WSxktzoMP8JoYWKeopChy6G3HKk4UQ2hESCRDUUTZqQ3+nM3u8noVG0LmNfRWwcFBnbZ71GKC7vaYYdJ1g==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/instrumentation@0.57.2":
+ resolution:
+ {
+ integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==,
+ }
+ engines: { node: ">=14" }
+ peerDependencies:
+ "@opentelemetry/api": ^1.3.0
+
+ "@opentelemetry/redis-common@0.38.0":
+ resolution:
+ {
+ integrity: sha512-4Wc0AWURII2cfXVVoZ6vDqK+s5n4K5IssdrlVrvGsx6OEOKdghKtJZqXAHWFiZv4nTDLH2/2fldjIHY8clMOjQ==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+
+ "@opentelemetry/resources@2.1.0":
+ resolution:
+ {
+ integrity: sha512-1CJjf3LCvoefUOgegxi8h6r4B/wLSzInyhGP2UmIBYNlo4Qk5CZ73e1eEyWmfXvFtm1ybkmfb2DqWvspsYLrWw==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+
+ "@opentelemetry/sdk-trace-base@2.1.0":
+ resolution:
+ {
+ integrity: sha512-uTX9FBlVQm4S2gVQO1sb5qyBLq/FPjbp+tmGoxu4tIgtYGmBYB44+KX/725RFDe30yBSaA9Ml9fqphe1hbUyLQ==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ">=1.3.0 <1.10.0"
+
+ "@opentelemetry/semantic-conventions@1.37.0":
+ resolution:
+ {
+ integrity: sha512-JD6DerIKdJGmRp4jQyX5FlrQjA4tjOw1cvfsPAZXfOOEErMUHjPcPSICS+6WnM0nB0efSFARh0KAZss+bvExOA==,
+ }
+ engines: { node: ">=14" }
+
+ "@opentelemetry/sql-common@0.41.0":
+ resolution:
+ {
+ integrity: sha512-pmzXctVbEERbqSfiAgdes9Y63xjoOyXcD7B6IXBkVb+vbM7M9U98mn33nGXxPf4dfYR0M+vhcKRZmbSJ7HfqFA==,
+ }
+ engines: { node: ^18.19.0 || >=20.6.0 }
+ peerDependencies:
+ "@opentelemetry/api": ^1.1.0
+
"@pandacss/is-valid-prop@0.54.0":
resolution:
{
@@ -1255,6 +1754,14 @@ packages:
}
engines: { node: ^12.20.0 || ^14.18.0 || >=16.0.0 }
+ "@prisma/instrumentation@6.14.0":
+ resolution:
+ {
+ integrity: sha512-Po/Hry5bAeunRDq0yAQueKookW3glpP+qjjvvyOfm6dI2KG5/Y6Bgg3ahyWd7B0u2E+Wf9xRk2rtdda7ySgK1A==,
+ }
+ peerDependencies:
+ "@opentelemetry/api": ^1.8
+
"@radix-ui/primitive@1.1.3":
resolution:
{
@@ -1572,14 +2079,14 @@ packages:
react-redux:
optional: true
- "@rollup/plugin-commonjs@24.0.0":
+ "@rollup/plugin-commonjs@28.0.1":
resolution:
{
- integrity: sha512-0w0wyykzdyRRPHOb0cQt14mIBLujfAv6GgP6g8nvg/iBxEm112t3YPPq+Buqe2+imvElTka+bjNlJ/gB56TD8g==,
+ integrity: sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==,
}
- engines: { node: ">=14.0.0" }
+ engines: { node: ">=16.0.0 || 14 >= 14.17" }
peerDependencies:
- rollup: ^2.68.0||^3.0.0
+ rollup: ^2.68.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
@@ -1596,6 +2103,174 @@ packages:
rollup:
optional: true
+ "@rollup/rollup-android-arm-eabi@4.50.1":
+ resolution:
+ {
+ integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==,
+ }
+ cpu: [arm]
+ os: [android]
+
+ "@rollup/rollup-android-arm64@4.50.1":
+ resolution:
+ {
+ integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==,
+ }
+ cpu: [arm64]
+ os: [android]
+
+ "@rollup/rollup-darwin-arm64@4.50.1":
+ resolution:
+ {
+ integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==,
+ }
+ cpu: [arm64]
+ os: [darwin]
+
+ "@rollup/rollup-darwin-x64@4.50.1":
+ resolution:
+ {
+ integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==,
+ }
+ cpu: [x64]
+ os: [darwin]
+
+ "@rollup/rollup-freebsd-arm64@4.50.1":
+ resolution:
+ {
+ integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==,
+ }
+ cpu: [arm64]
+ os: [freebsd]
+
+ "@rollup/rollup-freebsd-x64@4.50.1":
+ resolution:
+ {
+ integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==,
+ }
+ cpu: [x64]
+ os: [freebsd]
+
+ "@rollup/rollup-linux-arm-gnueabihf@4.50.1":
+ resolution:
+ {
+ integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==,
+ }
+ cpu: [arm]
+ os: [linux]
+
+ "@rollup/rollup-linux-arm-musleabihf@4.50.1":
+ resolution:
+ {
+ integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==,
+ }
+ cpu: [arm]
+ os: [linux]
+
+ "@rollup/rollup-linux-arm64-gnu@4.50.1":
+ resolution:
+ {
+ integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==,
+ }
+ cpu: [arm64]
+ os: [linux]
+
+ "@rollup/rollup-linux-arm64-musl@4.50.1":
+ resolution:
+ {
+ integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==,
+ }
+ cpu: [arm64]
+ os: [linux]
+
+ "@rollup/rollup-linux-loongarch64-gnu@4.50.1":
+ resolution:
+ {
+ integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==,
+ }
+ cpu: [loong64]
+ os: [linux]
+
+ "@rollup/rollup-linux-ppc64-gnu@4.50.1":
+ resolution:
+ {
+ integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==,
+ }
+ cpu: [ppc64]
+ os: [linux]
+
+ "@rollup/rollup-linux-riscv64-gnu@4.50.1":
+ resolution:
+ {
+ integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==,
+ }
+ cpu: [riscv64]
+ os: [linux]
+
+ "@rollup/rollup-linux-riscv64-musl@4.50.1":
+ resolution:
+ {
+ integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==,
+ }
+ cpu: [riscv64]
+ os: [linux]
+
+ "@rollup/rollup-linux-s390x-gnu@4.50.1":
+ resolution:
+ {
+ integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==,
+ }
+ cpu: [s390x]
+ os: [linux]
+
+ "@rollup/rollup-linux-x64-gnu@4.50.1":
+ resolution:
+ {
+ integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==,
+ }
+ cpu: [x64]
+ os: [linux]
+
+ "@rollup/rollup-linux-x64-musl@4.50.1":
+ resolution:
+ {
+ integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==,
+ }
+ cpu: [x64]
+ os: [linux]
+
+ "@rollup/rollup-openharmony-arm64@4.50.1":
+ resolution:
+ {
+ integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==,
+ }
+ cpu: [arm64]
+ os: [openharmony]
+
+ "@rollup/rollup-win32-arm64-msvc@4.50.1":
+ resolution:
+ {
+ integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==,
+ }
+ cpu: [arm64]
+ os: [win32]
+
+ "@rollup/rollup-win32-ia32-msvc@4.50.1":
+ resolution:
+ {
+ integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==,
+ }
+ cpu: [ia32]
+ os: [win32]
+
+ "@rollup/rollup-win32-x64-msvc@4.50.1":
+ resolution:
+ {
+ integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==,
+ }
+ cpu: [x64]
+ os: [win32]
+
"@rtsao/scc@1.1.0":
resolution:
{
@@ -1608,106 +2283,209 @@ packages:
integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==,
}
- "@sentry-internal/tracing@7.77.0":
+ "@sentry-internal/browser-utils@10.11.0":
resolution:
{
- integrity: sha512-8HRF1rdqWwtINqGEdx8Iqs9UOP/n8E0vXUu3Nmbqj4p5sQPA7vvCfq+4Y4rTqZFc7sNdFpDsRION5iQEh8zfZw==,
+ integrity: sha512-fnMlz5ntap6x4vRsLOHwPqXh7t82StgAiRt+EaqcMX0t9l8C0w0df8qwrONKXvE5GdHWTNFJj5qR15FERSkg3Q==,
}
- engines: { node: ">=8" }
+ engines: { node: ">=18" }
- "@sentry/browser@7.77.0":
+ "@sentry-internal/feedback@10.11.0":
resolution:
{
- integrity: sha512-nJ2KDZD90H8jcPx9BysQLiQW+w7k7kISCWeRjrEMJzjtge32dmHA8G4stlUTRIQugy5F+73cOayWShceFP7QJQ==,
+ integrity: sha512-ADey51IIaa29kepb8B7aSgSGSrcyT7QZdRsN1rhitefzrruHzpSUci5c2EPIvmWfKJq8Wnvukm9BHXZXAAIOzA==,
}
- engines: { node: ">=8" }
+ engines: { node: ">=18" }
- "@sentry/cli@1.77.3":
+ "@sentry-internal/replay-canvas@10.11.0":
resolution:
{
- integrity: sha512-c3eDqcDRmy4TFz2bFU5Y6QatlpoBPPa8cxBooaS4aMQpnIdLYPF1xhyyiW0LQlDUNc3rRjNF7oN5qKoaRoMTQQ==,
+ integrity: sha512-brWQ90IYQyZr44IpTprlmvbtz4l2ABzLdpP94Egh12Onf/q6n4CjLKaA25N5kX0uggHqX1Rs7dNaG0mP3ETHhA==,
}
- engines: { node: ">= 8" }
+ engines: { node: ">=18" }
+
+ "@sentry-internal/replay@10.11.0":
+ resolution:
+ {
+ integrity: sha512-t4M2bxMp2rKGK/l7bkVWjN+xVw9H9V12jAeXmO/Fskz2RcG1ZNLQnKSx/W/zCRMk8k7xOQFsfiApq+zDN+ziKA==,
+ }
+ engines: { node: ">=18" }
+
+ "@sentry/babel-plugin-component-annotate@4.3.0":
+ resolution:
+ {
+ integrity: sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==,
+ }
+ engines: { node: ">= 14" }
+
+ "@sentry/browser@10.11.0":
+ resolution:
+ {
+ integrity: sha512-qemaKCJKJHHCyGBpdLq23xL5u9Xvir20XN7YFTnHcEq4Jvj0GoWsslxKi5cQB2JvpYn62WxTiDgVLeQlleZhSg==,
+ }
+ engines: { node: ">=18" }
+
+ "@sentry/bundler-plugin-core@4.3.0":
+ resolution:
+ {
+ integrity: sha512-dmR4DJhJ4jqVWGWppuTL2blNFqOZZnt4aLkewbD1myFG3KVfUx8CrMQWEmGjkgPOtj5TO6xH9PyTJjXC6o5tnA==,
+ }
+ engines: { node: ">= 14" }
+
+ "@sentry/cli-darwin@2.53.0":
+ resolution:
+ {
+ integrity: sha512-NNPfpILMwKgpHiyJubHHuauMKltkrgLQ5tvMdxNpxY60jBNdo5VJtpESp4XmXlnidzV4j1z61V4ozU6ttDgt5Q==,
+ }
+ engines: { node: ">=10" }
+ os: [darwin]
+
+ "@sentry/cli-linux-arm64@2.53.0":
+ resolution:
+ {
+ integrity: sha512-xY/CZ1dVazsSCvTXzKpAgXaRqfljVfdrFaYZRUaRPf1ZJRGa3dcrivoOhSIeG/p5NdYtMvslMPY9Gm2MT0M83A==,
+ }
+ engines: { node: ">=10" }
+ cpu: [arm64]
+ os: [linux, freebsd, android]
+
+ "@sentry/cli-linux-arm@2.53.0":
+ resolution:
+ {
+ integrity: sha512-NdRzQ15Ht83qG0/Lyu11ciy/Hu/oXbbtJUgwzACc7bWvHQA8xEwTsehWexqn1529Kfc5EjuZ0Wmj3MHmp+jOWw==,
+ }
+ engines: { node: ">=10" }
+ cpu: [arm]
+ os: [linux, freebsd, android]
+
+ "@sentry/cli-linux-i686@2.53.0":
+ resolution:
+ {
+ integrity: sha512-0REmBibGAB4jtqt9S6JEsFF4QybzcXHPcHtJjgMi5T0ueh952uG9wLzjSxQErCsxTKF+fL8oG0Oz5yKBuCwCCQ==,
+ }
+ engines: { node: ">=10" }
+ cpu: [x86, ia32]
+ os: [linux, freebsd, android]
+
+ "@sentry/cli-linux-x64@2.53.0":
+ resolution:
+ {
+ integrity: sha512-9UGJL+Vy5N/YL1EWPZ/dyXLkShlNaDNrzxx4G7mTS9ywjg+BIuemo6rnN7w43K1NOjObTVO6zY0FwumJ1pCyLg==,
+ }
+ engines: { node: ">=10" }
+ cpu: [x64]
+ os: [linux, freebsd, android]
+
+ "@sentry/cli-win32-arm64@2.53.0":
+ resolution:
+ {
+ integrity: sha512-G1kjOjrjMBY20rQcJV2GA8KQE74ufmROCDb2GXYRfjvb1fKAsm4Oh8N5+Tqi7xEHdjQoLPkE4CNW0aH68JSUDQ==,
+ }
+ engines: { node: ">=10" }
+ cpu: [arm64]
+ os: [win32]
+
+ "@sentry/cli-win32-i686@2.53.0":
+ resolution:
+ {
+ integrity: sha512-qbGTZUzesuUaPtY9rPXdNfwLqOZKXrJRC1zUFn52hdo6B+Dmv0m/AHwRVFHZP53Tg1NCa8bDei2K/uzRN0dUZw==,
+ }
+ engines: { node: ">=10" }
+ cpu: [x86, ia32]
+ os: [win32]
+
+ "@sentry/cli-win32-x64@2.53.0":
+ resolution:
+ {
+ integrity: sha512-1TXYxYHtwgUq5KAJt3erRzzUtPqg7BlH9T7MdSPHjJatkrr/kwZqnVe2H6Arr/5NH891vOlIeSPHBdgJUAD69g==,
+ }
+ engines: { node: ">=10" }
+ cpu: [x64]
+ os: [win32]
+
+ "@sentry/cli@2.53.0":
+ resolution:
+ {
+ integrity: sha512-n2ZNb+5Z6AZKQSI0SusQ7ZzFL637mfw3Xh4C3PEyVSn9LiF683fX0TTq8OeGmNZQS4maYfS95IFD+XpydU0dEA==,
+ }
+ engines: { node: ">= 10" }
hasBin: true
- "@sentry/core@7.77.0":
+ "@sentry/core@10.11.0":
resolution:
{
- integrity: sha512-Tj8oTYFZ/ZD+xW8IGIsU6gcFXD/gfE+FUxUaeSosd9KHwBQNOLhZSsYo/tTVf/rnQI/dQnsd4onPZLiL+27aTg==,
+ integrity: sha512-39Rxn8cDXConx3+SKOCAhW+/hklM7UDaz+U1OFzFMDlT59vXSpfI6bcXtNiFDrbOxlQ2hX8yAqx8YRltgSftoA==,
}
- engines: { node: ">=8" }
+ engines: { node: ">=18" }
- "@sentry/integrations@7.77.0":
+ "@sentry/nextjs@10.11.0":
resolution:
{
- integrity: sha512-P055qXgBHeZNKnnVEs5eZYLdy6P49Zr77A1aWJuNih/EenzMy922GOeGy2mF6XYrn1YJSjEwsNMNsQkcvMTK8Q==,
+ integrity: sha512-oMRmRW982H6kNlUHNij5QAro8Kbi43r3VrcrKtrx7LgjHOUTFUvZmJeynC+T+PcMgLhQNvCC3JgzOhfSqxOChg==,
}
- engines: { node: ">=8" }
-
- "@sentry/nextjs@7.77.0":
- resolution:
- {
- integrity: sha512-8tYPBt5luFjrng1sAMJqNjM9sq80q0jbt6yariADU9hEr7Zk8YqFaOI2/Q6yn9dZ6XyytIRtLEo54kk2AO94xw==,
- }
- engines: { node: ">=8" }
+ engines: { node: ">=18" }
peerDependencies:
- next: ^10.0.8 || ^11.0 || ^12.0 || ^13.0 || ^14.0
- react: 16.x || 17.x || 18.x
- webpack: ">= 4.0.0"
- peerDependenciesMeta:
- webpack:
- optional: true
+ next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0
- "@sentry/node@7.77.0":
+ "@sentry/node-core@10.11.0":
resolution:
{
- integrity: sha512-Ob5tgaJOj0OYMwnocc6G/CDLWC7hXfVvKX/ofkF98+BbN/tQa5poL+OwgFn9BA8ud8xKzyGPxGU6LdZ8Oh3z/g==,
+ integrity: sha512-dkVZ06F+W5W0CsD47ATTTOTTocmccT/ezrF9idspQq+HVOcjoKSU60WpWo22NjtVNdSYKLnom0q1LKRoaRA/Ww==,
}
- engines: { node: ">=8" }
-
- "@sentry/react@7.77.0":
- resolution:
- {
- integrity: sha512-Q+htKzib5em0MdaQZMmPomaswaU3xhcVqmLi2CxqQypSjbYgBPPd+DuhrXKoWYLDDkkbY2uyfe4Lp3yLRWeXYw==,
- }
- engines: { node: ">=8" }
+ engines: { node: ">=18" }
peerDependencies:
- react: 15.x || 16.x || 17.x || 18.x
+ "@opentelemetry/api": ^1.9.0
+ "@opentelemetry/context-async-hooks": ^1.30.1 || ^2.0.0
+ "@opentelemetry/core": ^1.30.1 || ^2.0.0
+ "@opentelemetry/instrumentation": ">=0.57.1 <1"
+ "@opentelemetry/resources": ^1.30.1 || ^2.0.0
+ "@opentelemetry/sdk-trace-base": ^1.30.1 || ^2.0.0
+ "@opentelemetry/semantic-conventions": ^1.34.0
- "@sentry/replay@7.77.0":
+ "@sentry/node@10.11.0":
resolution:
{
- integrity: sha512-M9Ik2J5ekl+C1Och3wzLRZVaRGK33BlnBwfwf3qKjgLDwfKW+1YkwDfTHbc2b74RowkJbOVNcp4m8ptlehlSaQ==,
+ integrity: sha512-Tbcjr3iQAEjYi7/QIpdS8afv/LU1TwDTiy5x87MSpVEoeFcZ7f2iFC4GV0fhB3p4qDuFdL2JGVsIIrzapp8Y4A==,
}
- engines: { node: ">=12" }
+ engines: { node: ">=18" }
- "@sentry/types@7.77.0":
+ "@sentry/opentelemetry@10.11.0":
resolution:
{
- integrity: sha512-nfb00XRJVi0QpDHg+JkqrmEBHsqBnxJu191Ded+Cs1OJ5oPXEW6F59LVcBScGvMqe+WEk1a73eH8XezwfgrTsA==,
+ integrity: sha512-BY2SsVlRKICzNUO9atUy064BZqYnhV5A/O+JjEx0kj7ylq+oZd++zmGkks00rSwaJE220cVcVhpwqxcFUpc2hw==,
}
- engines: { node: ">=8" }
+ engines: { node: ">=18" }
+ peerDependencies:
+ "@opentelemetry/api": ^1.9.0
+ "@opentelemetry/context-async-hooks": ^1.30.1 || ^2.0.0
+ "@opentelemetry/core": ^1.30.1 || ^2.0.0
+ "@opentelemetry/sdk-trace-base": ^1.30.1 || ^2.0.0
+ "@opentelemetry/semantic-conventions": ^1.34.0
- "@sentry/utils@7.77.0":
+ "@sentry/react@10.11.0":
resolution:
{
- integrity: sha512-NmM2kDOqVchrey3N5WSzdQoCsyDkQkiRxExPaNI2oKQ/jMWHs9yt0tSy7otPBcXs0AP59ihl75Bvm1tDRcsp5g==,
+ integrity: sha512-bE4lJ5Ni/n9JUdLWGG99yucY0/zOUXjKl9gfSTkvUvOiAIX/bY0Y4WgOqeWySvbMz679ZdOwF34k8RA/gI7a8g==,
}
- engines: { node: ">=8" }
+ engines: { node: ">=18" }
+ peerDependencies:
+ react: ^16.14.0 || 17.x || 18.x || 19.x
- "@sentry/vercel-edge@7.77.0":
+ "@sentry/vercel-edge@10.11.0":
resolution:
{
- integrity: sha512-ffddPCgxVeAccPYuH5sooZeHBqDuJ9OIhIRYKoDi4TvmwAzWo58zzZWhRpkHqHgIQdQvhLVZ5F+FSQVWnYSOkw==,
+ integrity: sha512-jAsJ8RbbF2JWj2wnXfd6BwWxCR6GBITMtlaoWc7pG22HknEtoH15dKsQC3Ew5r/KRcofr2e+ywdnBn5CPr1Pbg==,
}
- engines: { node: ">=8" }
+ engines: { node: ">=18" }
- "@sentry/webpack-plugin@1.20.0":
+ "@sentry/webpack-plugin@4.3.0":
resolution:
{
- integrity: sha512-Ssj1mJVFsfU6vMCOM2d+h+KQR7QHSfeIP16t4l20Uq/neqWXZUQ2yvQfe4S3BjdbJXz/X4Rw8Hfy1Sd0ocunYw==,
+ integrity: sha512-K4nU1SheK/tvyakBws2zfd+MN6hzmpW+wPTbSbDWn1+WL9+g9hsPh8hjFFiVe47AhhUoUZ3YgiH2HyeHXjHflA==,
}
- engines: { node: ">= 8" }
+ engines: { node: ">= 14" }
+ peerDependencies:
+ webpack: ">=4.40.0"
"@sinclair/typebox@0.27.8":
resolution:
@@ -1751,10 +2529,10 @@ packages:
integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==,
}
- "@swc/counter@0.1.3":
+ "@swc/helpers@0.5.15":
resolution:
{
- integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==,
+ integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==,
}
"@swc/helpers@0.5.17":
@@ -1763,12 +2541,6 @@ packages:
integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==,
}
- "@swc/helpers@0.5.5":
- resolution:
- {
- integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==,
- }
-
"@tanstack/query-core@5.85.9":
resolution:
{
@@ -1837,6 +2609,12 @@ packages:
integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==,
}
+ "@types/connect@3.4.38":
+ resolution:
+ {
+ integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==,
+ }
+
"@types/debug@4.1.12":
resolution:
{
@@ -1934,6 +2712,12 @@ packages:
integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==,
}
+ "@types/mysql@2.15.27":
+ resolution:
+ {
+ integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==,
+ }
+
"@types/node-fetch@2.6.13":
resolution:
{
@@ -1946,12 +2730,30 @@ packages:
integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==,
}
+ "@types/node@24.3.1":
+ resolution:
+ {
+ integrity: sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==,
+ }
+
"@types/parse-json@4.0.2":
resolution:
{
integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==,
}
+ "@types/pg-pool@2.0.6":
+ resolution:
+ {
+ integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==,
+ }
+
+ "@types/pg@8.15.4":
+ resolution:
+ {
+ integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==,
+ }
+
"@types/prop-types@15.7.15":
resolution:
{
@@ -1970,12 +2772,24 @@ packages:
integrity: sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA==,
}
+ "@types/shimmer@1.2.0":
+ resolution:
+ {
+ integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==,
+ }
+
"@types/stack-utils@2.0.3":
resolution:
{
integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==,
}
+ "@types/tedious@4.0.14":
+ resolution:
+ {
+ integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==,
+ }
+
"@types/ua-parser-js@0.7.39":
resolution:
{
@@ -2830,6 +3644,14 @@ packages:
integrity: sha512-yI/CZizbk387TdkDCy9Uc4l53uaeQuWAIJESrmAwwq6yMNbHZ2dm5+1NHdZr/guES5TgyJa/BYJsNJeCsCfesg==,
}
+ acorn-import-attributes@1.9.5:
+ resolution:
+ {
+ integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==,
+ }
+ peerDependencies:
+ acorn: ^8
+
acorn-import-phases@1.0.4:
resolution:
{
@@ -3264,13 +4086,6 @@ packages:
integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==,
}
- busboy@1.6.0:
- resolution:
- {
- integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==,
- }
- engines: { node: ">=10.16.0" }
-
call-bind-apply-helpers@1.0.2:
resolution:
{
@@ -3424,6 +4239,12 @@ packages:
}
engines: { node: ">=8" }
+ cjs-module-lexer@1.4.3:
+ resolution:
+ {
+ integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==,
+ }
+
cjs-module-lexer@2.1.0:
resolution:
{
@@ -3489,6 +4310,19 @@ packages:
integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==,
}
+ color-string@1.9.1:
+ resolution:
+ {
+ integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==,
+ }
+
+ color@4.2.3:
+ resolution:
+ {
+ integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==,
+ }
+ engines: { node: ">=12.5.0" }
+
colorette@1.4.0:
resolution:
{
@@ -3727,6 +4561,13 @@ packages:
engines: { node: ">=0.10" }
hasBin: true
+ detect-libc@2.0.4:
+ resolution:
+ {
+ integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==,
+ }
+ engines: { node: ">=8" }
+
detect-newline@3.1.0:
resolution:
{
@@ -3790,6 +4631,13 @@ packages:
integrity: sha512-h7g5eduvnLwowJJPkcB5lNzo8vd/Hx4e3I4IOtLpX0qB2wBiuryGLNa61MeFre4b6gMaQIhegMIZ2I8rQCAJwQ==,
}
+ dotenv@16.6.1:
+ resolution:
+ {
+ integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==,
+ }
+ engines: { node: ">=12" }
+
dunder-proto@1.0.1:
resolution:
{
@@ -3943,13 +4791,13 @@ packages:
}
engines: { node: ">=10" }
- eslint-config-next@14.2.31:
+ eslint-config-next@15.5.3:
resolution:
{
- integrity: sha512-sT32j4678je7SWstBM6l0kE2L+LSgAARDAxw8iloNhI4/8xwkdDesbrGCPaGWzQv+dD6f6adhB+eRSThpGkBdg==,
+ integrity: sha512-e6j+QhQFOr5pfsc8VJbuTD9xTXJaRvMHYjEeLPA2pFkheNlgPLCkxdvhxhfuM4KGcqSZj2qEnpHisdTVs3BxuQ==,
}
peerDependencies:
- eslint: ^7.23.0 || ^8.0.0
+ eslint: ^7.23.0 || ^8.0.0 || ^9.0.0
typescript: ">=3.3.1"
peerDependenciesMeta:
typescript:
@@ -4023,14 +4871,14 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9
- eslint-plugin-react-hooks@5.0.0-canary-7118f5dd7-20230705:
+ eslint-plugin-react-hooks@5.2.0:
resolution:
{
- integrity: sha512-AZYbMo/NW9chdL7vk6HQzQhT+PvTAEVqWk9ziruUoW2kAOcN5qNyelv70e0F1VNQAbvutOC9oc+xfWycI9FxDw==,
+ integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==,
}
engines: { node: ">=10" }
peerDependencies:
- eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
eslint-plugin-react@7.37.5:
resolution:
@@ -4197,6 +5045,13 @@ packages:
integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==,
}
+ fast-glob@3.3.1:
+ resolution:
+ {
+ integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==,
+ }
+ engines: { node: ">=8.6.0" }
+
fast-glob@3.3.3:
resolution:
{
@@ -4337,6 +5192,12 @@ packages:
}
engines: { node: ">= 6" }
+ forwarded-parse@2.1.2:
+ resolution:
+ {
+ integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==,
+ }
+
fraction.js@4.3.7:
resolution:
{
@@ -4464,14 +5325,6 @@ packages:
integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==,
}
- glob@10.3.10:
- resolution:
- {
- integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==,
- }
- engines: { node: ">=16 || 14 >=14.17" }
- hasBin: true
-
glob@10.4.5:
resolution:
{
@@ -4486,13 +5339,12 @@ packages:
}
deprecated: Glob versions prior to v9 are no longer supported
- glob@8.1.0:
+ glob@9.3.5:
resolution:
{
- integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==,
+ integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==,
}
- engines: { node: ">=12" }
- deprecated: Glob versions prior to v9 are no longer supported
+ engines: { node: ">=16 || 14 >=14.17" }
globals@14.0.0:
resolution:
@@ -4673,12 +5525,6 @@ packages:
}
engines: { node: ">= 4" }
- immediate@3.0.6:
- resolution:
- {
- integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==,
- }
-
immer@10.1.1:
resolution:
{
@@ -4698,6 +5544,12 @@ packages:
}
engines: { node: ">=6" }
+ import-in-the-middle@1.14.2:
+ resolution:
+ {
+ integrity: sha512-5tCuY9BV8ujfOpwtAGgsTx9CGUapcFMEEyByLv1B+v2+6DhAcw+Zr0nhQT7uwaZ7DiourxFEscghOR8e1aPLQw==,
+ }
+
import-local@3.2.0:
resolution:
{
@@ -4798,6 +5650,12 @@ packages:
integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==,
}
+ is-arrayish@0.3.2:
+ resolution:
+ {
+ integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==,
+ }
+
is-async-function@2.1.1:
resolution:
{
@@ -5085,13 +5943,6 @@ packages:
}
engines: { node: ">= 0.4" }
- jackspeak@2.3.6:
- resolution:
- {
- integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==,
- }
- engines: { node: ">=14" }
-
jackspeak@3.4.3:
resolution:
{
@@ -5461,12 +6312,6 @@ packages:
}
engines: { node: ">= 0.8.0" }
- lie@3.1.1:
- resolution:
- {
- integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==,
- }
-
lighterhtml@4.2.0:
resolution:
{
@@ -5493,12 +6338,6 @@ packages:
}
engines: { node: ">=6.11.5" }
- localforage@1.10.0:
- resolution:
- {
- integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==,
- }
-
locate-path@5.0.0:
resolution:
{
@@ -5577,10 +6416,16 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
- magic-string@0.27.0:
+ magic-string@0.30.19:
resolution:
{
- integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==,
+ integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==,
+ }
+
+ magic-string@0.30.8:
+ resolution:
+ {
+ integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==,
}
engines: { node: ">=12" }
@@ -5845,6 +6690,13 @@ packages:
}
engines: { node: ">=10" }
+ minimatch@8.0.4:
+ resolution:
+ {
+ integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==,
+ }
+ engines: { node: ">=16 || 14 >=14.17" }
+
minimatch@9.0.5:
resolution:
{
@@ -5858,6 +6710,13 @@ packages:
integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==,
}
+ minipass@4.2.8:
+ resolution:
+ {
+ integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==,
+ }
+ engines: { node: ">=8" }
+
minipass@7.1.2:
resolution:
{
@@ -5871,12 +6730,11 @@ packages:
integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==,
}
- mkdirp@0.5.6:
+ module-details-from-path@1.0.4:
resolution:
{
- integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==,
+ integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==,
}
- hasBin: true
ms@2.1.3:
resolution:
@@ -5944,24 +6802,27 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
- next@14.2.31:
+ next@15.5.3:
resolution:
{
- integrity: sha512-Wyw1m4t8PhqG+or5a1U/Deb888YApC4rAez9bGhHkTsfwAy4SWKVro0GhEx4sox1856IbLhvhce2hAA6o8vkog==,
+ integrity: sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw==,
}
- engines: { node: ">=18.17.0" }
+ engines: { node: ^18.18.0 || ^19.8.0 || >= 20.0.0 }
hasBin: true
peerDependencies:
"@opentelemetry/api": ^1.1.0
- "@playwright/test": ^1.41.2
- react: ^18.2.0
- react-dom: ^18.2.0
+ "@playwright/test": ^1.51.1
+ babel-plugin-react-compiler: "*"
+ react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
+ react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
sass: ^1.3.0
peerDependenciesMeta:
"@opentelemetry/api":
optional: true
"@playwright/test":
optional: true
+ babel-plugin-react-compiler:
+ optional: true
sass:
optional: true
@@ -6304,6 +7165,26 @@ packages:
integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==,
}
+ pg-int8@1.0.1:
+ resolution:
+ {
+ integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==,
+ }
+ engines: { node: ">=4.0.0" }
+
+ pg-protocol@1.10.3:
+ resolution:
+ {
+ integrity: sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==,
+ }
+
+ pg-types@2.2.0:
+ resolution:
+ {
+ integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==,
+ }
+ engines: { node: ">=4" }
+
picocolors@1.1.1:
resolution:
{
@@ -6428,6 +7309,34 @@ packages:
}
engines: { node: ^10 || ^12 || >=14 }
+ postgres-array@2.0.0:
+ resolution:
+ {
+ integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==,
+ }
+ engines: { node: ">=4" }
+
+ postgres-bytea@1.0.0:
+ resolution:
+ {
+ integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==,
+ }
+ engines: { node: ">=0.10.0" }
+
+ postgres-date@1.0.7:
+ resolution:
+ {
+ integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==,
+ }
+ engines: { node: ">=0.10.0" }
+
+ postgres-interval@1.2.0:
+ resolution:
+ {
+ integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==,
+ }
+ engines: { node: ">=0.10.0" }
+
preact-render-to-string@5.2.6:
resolution:
{
@@ -6750,6 +7659,13 @@ packages:
}
engines: { node: ">=0.10.0" }
+ require-in-the-middle@7.5.2:
+ resolution:
+ {
+ integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==,
+ }
+ engines: { node: ">=8.6.0" }
+
reraf@1.1.1:
resolution:
{
@@ -6818,12 +7734,12 @@ packages:
}
engines: { iojs: ">=1.0.0", node: ">=0.10.0" }
- rollup@2.78.0:
+ rollup@4.50.1:
resolution:
{
- integrity: sha512-4+YfbQC9QEVvKTanHhIAFVUFSRsezvQF8vFOJwtGfb9Bb+r014S+qryr9PSmw8x6sMnPkmFBGAvIFVQxvJxjtg==,
+ integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==,
}
- engines: { node: ">=10.0.0" }
+ engines: { node: ">=18.0.0", npm: ">=8.0.0" }
hasBin: true
rtcstats@https://codeload.github.com/whereby/rtcstats/tar.gz/63bcb6420d76d34161b39e494524ae73aa6dd70d:
@@ -6949,6 +7865,13 @@ packages:
}
engines: { node: ">= 0.4" }
+ sharp@0.34.3:
+ resolution:
+ {
+ integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==,
+ }
+ engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 }
+
shebang-command@2.0.0:
resolution:
{
@@ -6963,6 +7886,12 @@ packages:
}
engines: { node: ">=8" }
+ shimmer@1.2.1:
+ resolution:
+ {
+ integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==,
+ }
+
side-channel-list@1.0.0:
resolution:
{
@@ -7010,6 +7939,12 @@ packages:
integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==,
}
+ simple-swizzle@0.2.2:
+ resolution:
+ {
+ integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==,
+ }
+
slash@3.0.0:
resolution:
{
@@ -7115,13 +8050,6 @@ packages:
}
engines: { node: ">= 0.4" }
- streamsearch@1.1.0:
- resolution:
- {
- integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==,
- }
- engines: { node: ">=10.0.0" }
-
string-length@4.0.2:
resolution:
{
@@ -7250,16 +8178,16 @@ packages:
integrity: sha512-G4qppLgKu/k6FwRpHiGiKPaPTFcG3g4wNVX/Qsfu+RqQM30E7Tyu/TEgxcL9PNLF5pdRLwQdE3YKKf+KF2Dzlw==,
}
- styled-jsx@5.1.1:
+ styled-jsx@5.1.6:
resolution:
{
- integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==,
+ integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==,
}
engines: { node: ">= 12.0.0" }
peerDependencies:
"@babel/core": "*"
babel-plugin-macros: "*"
- react: ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ react: ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
peerDependenciesMeta:
"@babel/core":
optional: true
@@ -7674,6 +8602,12 @@ packages:
integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==,
}
+ unplugin@1.0.1:
+ resolution:
+ {
+ integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==,
+ }
+
unrs-resolver@1.11.1:
resolution:
{
@@ -7822,6 +8756,12 @@ packages:
}
engines: { node: ">=10.13.0" }
+ webpack-virtual-modules@0.5.0:
+ resolution:
+ {
+ integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==,
+ }
+
webpack@5.101.3:
resolution:
{
@@ -7946,6 +8886,13 @@ packages:
}
engines: { node: ">=0.4.0" }
+ xtend@4.0.2:
+ resolution:
+ {
+ integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==,
+ }
+ engines: { node: ">=0.4" }
+
y18n@5.0.8:
resolution:
{
@@ -8511,6 +9458,92 @@ snapshots:
"@humanwhocodes/retry@0.4.3": {}
+ "@img/sharp-darwin-arm64@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-darwin-arm64": 1.2.0
+ optional: true
+
+ "@img/sharp-darwin-x64@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-darwin-x64": 1.2.0
+ optional: true
+
+ "@img/sharp-libvips-darwin-arm64@1.2.0":
+ optional: true
+
+ "@img/sharp-libvips-darwin-x64@1.2.0":
+ optional: true
+
+ "@img/sharp-libvips-linux-arm64@1.2.0":
+ optional: true
+
+ "@img/sharp-libvips-linux-arm@1.2.0":
+ optional: true
+
+ "@img/sharp-libvips-linux-ppc64@1.2.0":
+ optional: true
+
+ "@img/sharp-libvips-linux-s390x@1.2.0":
+ optional: true
+
+ "@img/sharp-libvips-linux-x64@1.2.0":
+ optional: true
+
+ "@img/sharp-libvips-linuxmusl-arm64@1.2.0":
+ optional: true
+
+ "@img/sharp-libvips-linuxmusl-x64@1.2.0":
+ optional: true
+
+ "@img/sharp-linux-arm64@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-linux-arm64": 1.2.0
+ optional: true
+
+ "@img/sharp-linux-arm@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-linux-arm": 1.2.0
+ optional: true
+
+ "@img/sharp-linux-ppc64@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-linux-ppc64": 1.2.0
+ optional: true
+
+ "@img/sharp-linux-s390x@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-linux-s390x": 1.2.0
+ optional: true
+
+ "@img/sharp-linux-x64@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-linux-x64": 1.2.0
+ optional: true
+
+ "@img/sharp-linuxmusl-arm64@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-linuxmusl-arm64": 1.2.0
+ optional: true
+
+ "@img/sharp-linuxmusl-x64@0.34.3":
+ optionalDependencies:
+ "@img/sharp-libvips-linuxmusl-x64": 1.2.0
+ optional: true
+
+ "@img/sharp-wasm32@0.34.3":
+ dependencies:
+ "@emnapi/runtime": 1.4.5
+ optional: true
+
+ "@img/sharp-win32-arm64@0.34.3":
+ optional: true
+
+ "@img/sharp-win32-ia32@0.34.3":
+ optional: true
+
+ "@img/sharp-win32-x64@0.34.3":
+ optional: true
+
"@internationalized/date@3.8.2":
dependencies:
"@swc/helpers": 0.5.17
@@ -8742,8 +9775,7 @@ snapshots:
"@jridgewell/source-map@0.3.11":
dependencies:
"@jridgewell/gen-mapping": 0.3.13
- "@jridgewell/trace-mapping": 0.3.30
- optional: true
+ "@jridgewell/trace-mapping": 0.3.31
"@jridgewell/sourcemap-codec@1.5.5": {}
@@ -8752,6 +9784,11 @@ snapshots:
"@jridgewell/resolve-uri": 3.1.2
"@jridgewell/sourcemap-codec": 1.5.5
+ "@jridgewell/trace-mapping@0.3.31":
+ dependencies:
+ "@jridgewell/resolve-uri": 3.1.2
+ "@jridgewell/sourcemap-codec": 1.5.5
+
"@jridgewell/trace-mapping@0.3.9":
dependencies:
"@jridgewell/resolve-uri": 3.1.2
@@ -8765,37 +9802,34 @@ snapshots:
"@tybys/wasm-util": 0.10.0
optional: true
- "@next/env@14.2.31": {}
+ "@next/env@15.5.3": {}
- "@next/eslint-plugin-next@14.2.31":
+ "@next/eslint-plugin-next@15.5.3":
dependencies:
- glob: 10.3.10
+ fast-glob: 3.3.1
- "@next/swc-darwin-arm64@14.2.31":
+ "@next/swc-darwin-arm64@15.5.3":
optional: true
- "@next/swc-darwin-x64@14.2.31":
+ "@next/swc-darwin-x64@15.5.3":
optional: true
- "@next/swc-linux-arm64-gnu@14.2.31":
+ "@next/swc-linux-arm64-gnu@15.5.3":
optional: true
- "@next/swc-linux-arm64-musl@14.2.31":
+ "@next/swc-linux-arm64-musl@15.5.3":
optional: true
- "@next/swc-linux-x64-gnu@14.2.31":
+ "@next/swc-linux-x64-gnu@15.5.3":
optional: true
- "@next/swc-linux-x64-musl@14.2.31":
+ "@next/swc-linux-x64-musl@15.5.3":
optional: true
- "@next/swc-win32-arm64-msvc@14.2.31":
+ "@next/swc-win32-arm64-msvc@15.5.3":
optional: true
- "@next/swc-win32-ia32-msvc@14.2.31":
- optional: true
-
- "@next/swc-win32-x64-msvc@14.2.31":
+ "@next/swc-win32-x64-msvc@15.5.3":
optional: true
"@nodelib/fs.scandir@2.1.5":
@@ -8812,8 +9846,275 @@ snapshots:
"@nolyfill/is-core-module@1.0.39": {}
- "@opentelemetry/api@1.9.0":
- optional: true
+ "@opentelemetry/api-logs@0.203.0":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+
+ "@opentelemetry/api-logs@0.204.0":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+
+ "@opentelemetry/api-logs@0.57.2":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+
+ "@opentelemetry/api@1.9.0": {}
+
+ "@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+
+ "@opentelemetry/core@2.0.1(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/semantic-conventions": 1.37.0
+
+ "@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/semantic-conventions": 1.37.0
+
+ "@opentelemetry/instrumentation-amqplib@0.50.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-connect@0.47.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@types/connect": 3.4.38
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-dataloader@0.21.1(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-express@0.52.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-fs@0.23.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-generic-pool@0.47.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-graphql@0.51.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-hapi@0.50.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-http@0.203.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.0.1(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ forwarded-parse: 2.1.2
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-ioredis@0.52.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.204.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/redis-common": 0.38.0
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-kafkajs@0.13.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-knex@0.48.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-koa@0.51.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-lru-memoizer@0.48.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-mongodb@0.56.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-mongoose@0.50.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-mysql2@0.50.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@opentelemetry/sql-common": 0.41.0(@opentelemetry/api@1.9.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-mysql@0.49.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@types/mysql": 2.15.27
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-pg@0.55.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@opentelemetry/sql-common": 0.41.0(@opentelemetry/api@1.9.0)
+ "@types/pg": 8.15.4
+ "@types/pg-pool": 2.0.6
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-redis@0.51.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/redis-common": 0.38.0
+ "@opentelemetry/semantic-conventions": 1.37.0
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-tedious@0.22.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@types/tedious": 4.0.14
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation-undici@0.14.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/api-logs": 0.203.0
+ import-in-the-middle: 1.14.2
+ require-in-the-middle: 7.5.2
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation@0.204.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/api-logs": 0.204.0
+ import-in-the-middle: 1.14.2
+ require-in-the-middle: 7.5.2
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/api-logs": 0.57.2
+ "@types/shimmer": 1.2.0
+ import-in-the-middle: 1.14.2
+ require-in-the-middle: 7.5.2
+ semver: 7.7.2
+ shimmer: 1.2.1
+ transitivePeerDependencies:
+ - supports-color
+
+ "@opentelemetry/redis-common@0.38.0": {}
+
+ "@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+
+ "@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/resources": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+
+ "@opentelemetry/semantic-conventions@1.37.0": {}
+
+ "@opentelemetry/sql-common@0.41.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
"@pandacss/is-valid-prop@0.54.0": {}
@@ -8885,6 +10186,13 @@ snapshots:
"@pkgr/core@0.2.9": {}
+ "@prisma/instrumentation@6.14.0(@opentelemetry/api@1.9.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/instrumentation": 0.57.2(@opentelemetry/api@1.9.0)
+ transitivePeerDependencies:
+ - supports-color
+
"@radix-ui/primitive@1.1.3": {}
"@radix-ui/react-arrow@1.1.7(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)":
@@ -9098,131 +10406,289 @@ snapshots:
optionalDependencies:
react: 18.3.1
- "@rollup/plugin-commonjs@24.0.0(rollup@2.78.0)":
+ "@rollup/plugin-commonjs@28.0.1(rollup@4.50.1)":
dependencies:
- "@rollup/pluginutils": 5.2.0(rollup@2.78.0)
+ "@rollup/pluginutils": 5.2.0(rollup@4.50.1)
commondir: 1.0.1
estree-walker: 2.0.2
- glob: 8.1.0
+ fdir: 6.4.6(picomatch@4.0.3)
is-reference: 1.2.1
- magic-string: 0.27.0
+ magic-string: 0.30.19
+ picomatch: 4.0.3
optionalDependencies:
- rollup: 2.78.0
+ rollup: 4.50.1
- "@rollup/pluginutils@5.2.0(rollup@2.78.0)":
+ "@rollup/pluginutils@5.2.0(rollup@4.50.1)":
dependencies:
"@types/estree": 1.0.8
estree-walker: 2.0.2
picomatch: 4.0.3
optionalDependencies:
- rollup: 2.78.0
+ rollup: 4.50.1
+
+ "@rollup/rollup-android-arm-eabi@4.50.1":
+ optional: true
+
+ "@rollup/rollup-android-arm64@4.50.1":
+ optional: true
+
+ "@rollup/rollup-darwin-arm64@4.50.1":
+ optional: true
+
+ "@rollup/rollup-darwin-x64@4.50.1":
+ optional: true
+
+ "@rollup/rollup-freebsd-arm64@4.50.1":
+ optional: true
+
+ "@rollup/rollup-freebsd-x64@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-arm-gnueabihf@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-arm-musleabihf@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-arm64-gnu@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-arm64-musl@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-loongarch64-gnu@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-ppc64-gnu@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-riscv64-gnu@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-riscv64-musl@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-s390x-gnu@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-x64-gnu@4.50.1":
+ optional: true
+
+ "@rollup/rollup-linux-x64-musl@4.50.1":
+ optional: true
+
+ "@rollup/rollup-openharmony-arm64@4.50.1":
+ optional: true
+
+ "@rollup/rollup-win32-arm64-msvc@4.50.1":
+ optional: true
+
+ "@rollup/rollup-win32-ia32-msvc@4.50.1":
+ optional: true
+
+ "@rollup/rollup-win32-x64-msvc@4.50.1":
+ optional: true
"@rtsao/scc@1.1.0": {}
"@rushstack/eslint-patch@1.12.0": {}
- "@sentry-internal/tracing@7.77.0":
+ "@sentry-internal/browser-utils@10.11.0":
dependencies:
- "@sentry/core": 7.77.0
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
+ "@sentry/core": 10.11.0
- "@sentry/browser@7.77.0":
+ "@sentry-internal/feedback@10.11.0":
dependencies:
- "@sentry-internal/tracing": 7.77.0
- "@sentry/core": 7.77.0
- "@sentry/replay": 7.77.0
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
+ "@sentry/core": 10.11.0
- "@sentry/cli@1.77.3":
+ "@sentry-internal/replay-canvas@10.11.0":
+ dependencies:
+ "@sentry-internal/replay": 10.11.0
+ "@sentry/core": 10.11.0
+
+ "@sentry-internal/replay@10.11.0":
+ dependencies:
+ "@sentry-internal/browser-utils": 10.11.0
+ "@sentry/core": 10.11.0
+
+ "@sentry/babel-plugin-component-annotate@4.3.0": {}
+
+ "@sentry/browser@10.11.0":
+ dependencies:
+ "@sentry-internal/browser-utils": 10.11.0
+ "@sentry-internal/feedback": 10.11.0
+ "@sentry-internal/replay": 10.11.0
+ "@sentry-internal/replay-canvas": 10.11.0
+ "@sentry/core": 10.11.0
+
+ "@sentry/bundler-plugin-core@4.3.0":
+ dependencies:
+ "@babel/core": 7.28.3
+ "@sentry/babel-plugin-component-annotate": 4.3.0
+ "@sentry/cli": 2.53.0
+ dotenv: 16.6.1
+ find-up: 5.0.0
+ glob: 9.3.5
+ magic-string: 0.30.8
+ unplugin: 1.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
+ "@sentry/cli-darwin@2.53.0":
+ optional: true
+
+ "@sentry/cli-linux-arm64@2.53.0":
+ optional: true
+
+ "@sentry/cli-linux-arm@2.53.0":
+ optional: true
+
+ "@sentry/cli-linux-i686@2.53.0":
+ optional: true
+
+ "@sentry/cli-linux-x64@2.53.0":
+ optional: true
+
+ "@sentry/cli-win32-arm64@2.53.0":
+ optional: true
+
+ "@sentry/cli-win32-i686@2.53.0":
+ optional: true
+
+ "@sentry/cli-win32-x64@2.53.0":
+ optional: true
+
+ "@sentry/cli@2.53.0":
dependencies:
https-proxy-agent: 5.0.1
- mkdirp: 0.5.6
node-fetch: 2.7.0
progress: 2.0.3
proxy-from-env: 1.1.0
which: 2.0.2
- transitivePeerDependencies:
- - encoding
- - supports-color
-
- "@sentry/core@7.77.0":
- dependencies:
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
-
- "@sentry/integrations@7.77.0":
- dependencies:
- "@sentry/core": 7.77.0
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
- localforage: 1.10.0
-
- "@sentry/nextjs@7.77.0(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)":
- dependencies:
- "@rollup/plugin-commonjs": 24.0.0(rollup@2.78.0)
- "@sentry/core": 7.77.0
- "@sentry/integrations": 7.77.0
- "@sentry/node": 7.77.0
- "@sentry/react": 7.77.0(react@18.3.1)
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
- "@sentry/vercel-edge": 7.77.0
- "@sentry/webpack-plugin": 1.20.0
- chalk: 3.0.0
- next: 14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
- react: 18.3.1
- resolve: 1.22.8
- rollup: 2.78.0
- stacktrace-parser: 0.1.11
optionalDependencies:
- webpack: 5.101.3
+ "@sentry/cli-darwin": 2.53.0
+ "@sentry/cli-linux-arm": 2.53.0
+ "@sentry/cli-linux-arm64": 2.53.0
+ "@sentry/cli-linux-i686": 2.53.0
+ "@sentry/cli-linux-x64": 2.53.0
+ "@sentry/cli-win32-arm64": 2.53.0
+ "@sentry/cli-win32-i686": 2.53.0
+ "@sentry/cli-win32-x64": 2.53.0
transitivePeerDependencies:
- encoding
- supports-color
- "@sentry/node@7.77.0":
+ "@sentry/core@10.11.0": {}
+
+ "@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)":
dependencies:
- "@sentry-internal/tracing": 7.77.0
- "@sentry/core": 7.77.0
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
- https-proxy-agent: 5.0.1
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@rollup/plugin-commonjs": 28.0.1(rollup@4.50.1)
+ "@sentry-internal/browser-utils": 10.11.0
+ "@sentry/bundler-plugin-core": 4.3.0
+ "@sentry/core": 10.11.0
+ "@sentry/node": 10.11.0
+ "@sentry/opentelemetry": 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)
+ "@sentry/react": 10.11.0(react@18.3.1)
+ "@sentry/vercel-edge": 10.11.0
+ "@sentry/webpack-plugin": 4.3.0(webpack@5.101.3)
+ chalk: 3.0.0
+ next: 15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ resolve: 1.22.8
+ rollup: 4.50.1
+ stacktrace-parser: 0.1.11
+ transitivePeerDependencies:
+ - "@opentelemetry/context-async-hooks"
+ - "@opentelemetry/core"
+ - "@opentelemetry/sdk-trace-base"
+ - encoding
+ - react
+ - supports-color
+ - webpack
+
+ "@sentry/node-core@10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/context-async-hooks": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/resources": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/sdk-trace-base": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@sentry/core": 10.11.0
+ "@sentry/opentelemetry": 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)
+ import-in-the-middle: 1.14.2
+
+ "@sentry/node@10.11.0":
+ dependencies:
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/context-async-hooks": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-amqplib": 0.50.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-connect": 0.47.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-dataloader": 0.21.1(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-express": 0.52.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-fs": 0.23.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-generic-pool": 0.47.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-graphql": 0.51.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-hapi": 0.50.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-http": 0.203.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-ioredis": 0.52.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-kafkajs": 0.13.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-knex": 0.48.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-koa": 0.51.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-lru-memoizer": 0.48.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-mongodb": 0.56.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-mongoose": 0.50.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-mysql": 0.49.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-mysql2": 0.50.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-pg": 0.55.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-redis": 0.51.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-tedious": 0.22.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/instrumentation-undici": 0.14.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/resources": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/sdk-trace-base": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@prisma/instrumentation": 6.14.0(@opentelemetry/api@1.9.0)
+ "@sentry/core": 10.11.0
+ "@sentry/node-core": 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)
+ "@sentry/opentelemetry": 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)
+ import-in-the-middle: 1.14.2
+ minimatch: 9.0.5
transitivePeerDependencies:
- supports-color
- "@sentry/react@7.77.0(react@18.3.1)":
+ "@sentry/opentelemetry@10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0)":
dependencies:
- "@sentry/browser": 7.77.0
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/context-async-hooks": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/core": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/sdk-trace-base": 2.1.0(@opentelemetry/api@1.9.0)
+ "@opentelemetry/semantic-conventions": 1.37.0
+ "@sentry/core": 10.11.0
+
+ "@sentry/react@10.11.0(react@18.3.1)":
+ dependencies:
+ "@sentry/browser": 10.11.0
+ "@sentry/core": 10.11.0
hoist-non-react-statics: 3.3.2
react: 18.3.1
- "@sentry/replay@7.77.0":
+ "@sentry/vercel-edge@10.11.0":
dependencies:
- "@sentry-internal/tracing": 7.77.0
- "@sentry/core": 7.77.0
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
+ "@opentelemetry/api": 1.9.0
+ "@opentelemetry/resources": 2.1.0(@opentelemetry/api@1.9.0)
+ "@sentry/core": 10.11.0
- "@sentry/types@7.77.0": {}
-
- "@sentry/utils@7.77.0":
+ "@sentry/webpack-plugin@4.3.0(webpack@5.101.3)":
dependencies:
- "@sentry/types": 7.77.0
-
- "@sentry/vercel-edge@7.77.0":
- dependencies:
- "@sentry/core": 7.77.0
- "@sentry/types": 7.77.0
- "@sentry/utils": 7.77.0
-
- "@sentry/webpack-plugin@1.20.0":
- dependencies:
- "@sentry/cli": 1.77.3
- webpack-sources: 3.3.3
+ "@sentry/bundler-plugin-core": 4.3.0
+ unplugin: 1.0.1
+ uuid: 9.0.1
+ webpack: 5.101.3
transitivePeerDependencies:
- encoding
- supports-color
@@ -9245,15 +10711,12 @@ snapshots:
"@standard-schema/utils@0.3.0": {}
- "@swc/counter@0.1.3": {}
-
- "@swc/helpers@0.5.17":
+ "@swc/helpers@0.5.15":
dependencies:
tslib: 2.8.1
- "@swc/helpers@0.5.5":
+ "@swc/helpers@0.5.17":
dependencies:
- "@swc/counter": 0.1.3
tslib: 2.8.1
"@tanstack/query-core@5.85.9": {}
@@ -9301,6 +10764,10 @@ snapshots:
dependencies:
"@babel/types": 7.28.2
+ "@types/connect@3.4.38":
+ dependencies:
+ "@types/node": 24.2.1
+
"@types/debug@4.1.12":
dependencies:
"@types/ms": 2.1.0
@@ -9309,13 +10776,11 @@ snapshots:
dependencies:
"@types/eslint": 9.6.1
"@types/estree": 1.0.8
- optional: true
"@types/eslint@9.6.1":
dependencies:
"@types/estree": 1.0.8
"@types/json-schema": 7.0.15
- optional: true
"@types/estree-jsx@1.0.5":
dependencies:
@@ -9360,6 +10825,10 @@ snapshots:
"@types/ms@2.1.0": {}
+ "@types/mysql@2.15.27":
+ dependencies:
+ "@types/node": 24.2.1
+
"@types/node-fetch@2.6.13":
dependencies:
"@types/node": 24.2.1
@@ -9369,8 +10838,22 @@ snapshots:
dependencies:
undici-types: 7.10.0
+ "@types/node@24.3.1":
+ dependencies:
+ undici-types: 7.10.0
+
"@types/parse-json@4.0.2": {}
+ "@types/pg-pool@2.0.6":
+ dependencies:
+ "@types/pg": 8.15.4
+
+ "@types/pg@8.15.4":
+ dependencies:
+ "@types/node": 24.2.1
+ pg-protocol: 1.10.3
+ pg-types: 2.2.0
+
"@types/prop-types@15.7.15": {}
"@types/react@18.2.20":
@@ -9381,8 +10864,14 @@ snapshots:
"@types/scheduler@0.26.0": {}
+ "@types/shimmer@1.2.0": {}
+
"@types/stack-utils@2.0.3": {}
+ "@types/tedious@4.0.14":
+ dependencies:
+ "@types/node": 24.2.1
+
"@types/ua-parser-js@0.7.39": {}
"@types/unist@2.0.11": {}
@@ -9565,26 +11054,20 @@ snapshots:
dependencies:
"@webassemblyjs/helper-numbers": 1.13.2
"@webassemblyjs/helper-wasm-bytecode": 1.13.2
- optional: true
- "@webassemblyjs/floating-point-hex-parser@1.13.2":
- optional: true
+ "@webassemblyjs/floating-point-hex-parser@1.13.2": {}
- "@webassemblyjs/helper-api-error@1.13.2":
- optional: true
+ "@webassemblyjs/helper-api-error@1.13.2": {}
- "@webassemblyjs/helper-buffer@1.14.1":
- optional: true
+ "@webassemblyjs/helper-buffer@1.14.1": {}
"@webassemblyjs/helper-numbers@1.13.2":
dependencies:
"@webassemblyjs/floating-point-hex-parser": 1.13.2
"@webassemblyjs/helper-api-error": 1.13.2
"@xtuc/long": 4.2.2
- optional: true
- "@webassemblyjs/helper-wasm-bytecode@1.13.2":
- optional: true
+ "@webassemblyjs/helper-wasm-bytecode@1.13.2": {}
"@webassemblyjs/helper-wasm-section@1.14.1":
dependencies:
@@ -9592,20 +11075,16 @@ snapshots:
"@webassemblyjs/helper-buffer": 1.14.1
"@webassemblyjs/helper-wasm-bytecode": 1.13.2
"@webassemblyjs/wasm-gen": 1.14.1
- optional: true
"@webassemblyjs/ieee754@1.13.2":
dependencies:
"@xtuc/ieee754": 1.2.0
- optional: true
"@webassemblyjs/leb128@1.13.2":
dependencies:
"@xtuc/long": 4.2.2
- optional: true
- "@webassemblyjs/utf8@1.13.2":
- optional: true
+ "@webassemblyjs/utf8@1.13.2": {}
"@webassemblyjs/wasm-edit@1.14.1":
dependencies:
@@ -9617,7 +11096,6 @@ snapshots:
"@webassemblyjs/wasm-opt": 1.14.1
"@webassemblyjs/wasm-parser": 1.14.1
"@webassemblyjs/wast-printer": 1.14.1
- optional: true
"@webassemblyjs/wasm-gen@1.14.1":
dependencies:
@@ -9626,7 +11104,6 @@ snapshots:
"@webassemblyjs/ieee754": 1.13.2
"@webassemblyjs/leb128": 1.13.2
"@webassemblyjs/utf8": 1.13.2
- optional: true
"@webassemblyjs/wasm-opt@1.14.1":
dependencies:
@@ -9634,7 +11111,6 @@ snapshots:
"@webassemblyjs/helper-buffer": 1.14.1
"@webassemblyjs/wasm-gen": 1.14.1
"@webassemblyjs/wasm-parser": 1.14.1
- optional: true
"@webassemblyjs/wasm-parser@1.14.1":
dependencies:
@@ -9644,13 +11120,11 @@ snapshots:
"@webassemblyjs/ieee754": 1.13.2
"@webassemblyjs/leb128": 1.13.2
"@webassemblyjs/utf8": 1.13.2
- optional: true
"@webassemblyjs/wast-printer@1.14.1":
dependencies:
"@webassemblyjs/ast": 1.14.1
"@xtuc/long": 4.2.2
- optional: true
"@whereby.com/browser-sdk@3.13.1(@types/react@18.2.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)":
dependencies:
@@ -9709,11 +11183,9 @@ snapshots:
- supports-color
- utf-8-validate
- "@xtuc/ieee754@1.2.0":
- optional: true
+ "@xtuc/ieee754@1.2.0": {}
- "@xtuc/long@4.2.2":
- optional: true
+ "@xtuc/long@4.2.2": {}
"@zag-js/accordion@1.21.0":
dependencies:
@@ -10216,10 +11688,13 @@ snapshots:
"@zag-js/utils@1.21.0": {}
+ acorn-import-attributes@1.9.5(acorn@8.15.0):
+ dependencies:
+ acorn: 8.15.0
+
acorn-import-phases@1.0.4(acorn@8.15.0):
dependencies:
acorn: 8.15.0
- optional: true
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
@@ -10243,13 +11718,11 @@ snapshots:
ajv-formats@2.1.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
- optional: true
ajv-keywords@5.1.0(ajv@8.17.1):
dependencies:
ajv: 8.17.1
fast-deep-equal: 3.1.3
- optional: true
ajv@6.12.6:
dependencies:
@@ -10264,7 +11737,6 @@ snapshots:
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
- optional: true
ansi-colors@4.1.3: {}
@@ -10523,10 +11995,6 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
- busboy@1.6.0:
- dependencies:
- streamsearch: 1.1.0
-
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -10598,13 +12066,14 @@ snapshots:
dependencies:
readdirp: 4.1.2
- chrome-trace-event@1.0.4:
- optional: true
+ chrome-trace-event@1.0.4: {}
ci-info@3.9.0: {}
ci-info@4.3.0: {}
+ cjs-module-lexer@1.4.3: {}
+
cjs-module-lexer@2.1.0: {}
classnames@2.5.1: {}
@@ -10631,6 +12100,18 @@ snapshots:
color-name@1.1.4: {}
+ color-string@1.9.1:
+ dependencies:
+ color-name: 1.1.4
+ simple-swizzle: 0.2.2
+ optional: true
+
+ color@4.2.3:
+ dependencies:
+ color-convert: 2.0.1
+ color-string: 1.9.1
+ optional: true
+
colorette@1.4.0: {}
combined-stream@1.0.8:
@@ -10639,8 +12120,7 @@ snapshots:
comma-separated-tokens@2.0.3: {}
- commander@2.20.3:
- optional: true
+ commander@2.20.3: {}
commander@4.1.1: {}
@@ -10750,6 +12230,9 @@ snapshots:
detect-libc@1.0.3:
optional: true
+ detect-libc@2.0.4:
+ optional: true
+
detect-newline@3.1.0: {}
detect-node-es@1.1.0: {}
@@ -10785,6 +12268,8 @@ snapshots:
domsanitizer: 0.2.3
umap: 1.0.2
+ dotenv@16.6.1: {}
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -10819,7 +12304,6 @@ snapshots:
dependencies:
graceful-fs: 4.2.11
tapable: 2.2.3
- optional: true
err-code@3.0.1: {}
@@ -10907,8 +12391,7 @@ snapshots:
iterator.prototype: 1.1.5
safe-array-concat: 1.1.3
- es-module-lexer@1.7.0:
- optional: true
+ es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
@@ -10937,9 +12420,9 @@ snapshots:
escape-string-regexp@4.0.0: {}
- eslint-config-next@14.2.31(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2):
+ eslint-config-next@15.5.3(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2):
dependencies:
- "@next/eslint-plugin-next": 14.2.31
+ "@next/eslint-plugin-next": 15.5.3
"@rushstack/eslint-patch": 1.12.0
"@typescript-eslint/eslint-plugin": 8.39.1(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2))(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2)
"@typescript-eslint/parser": 8.39.1(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2)
@@ -10949,7 +12432,7 @@ snapshots:
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.39.1(eslint@9.33.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.33.0(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.33.0(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.33.0(jiti@1.21.7))
- eslint-plugin-react-hooks: 5.0.0-canary-7118f5dd7-20230705(eslint@9.33.0(jiti@1.21.7))
+ eslint-plugin-react-hooks: 5.2.0(eslint@9.33.0(jiti@1.21.7))
optionalDependencies:
typescript: 5.9.2
transitivePeerDependencies:
@@ -11039,7 +12522,7 @@ snapshots:
safe-regex-test: 1.1.0
string.prototype.includes: 2.0.1
- eslint-plugin-react-hooks@5.0.0-canary-7118f5dd7-20230705(eslint@9.33.0(jiti@1.21.7)):
+ eslint-plugin-react-hooks@5.2.0(eslint@9.33.0(jiti@1.21.7)):
dependencies:
eslint: 9.33.0(jiti@1.21.7)
@@ -11069,7 +12552,6 @@ snapshots:
dependencies:
esrecurse: 4.3.0
estraverse: 4.3.0
- optional: true
eslint-scope@8.4.0:
dependencies:
@@ -11138,8 +12620,7 @@ snapshots:
dependencies:
estraverse: 5.3.0
- estraverse@4.3.0:
- optional: true
+ estraverse@4.3.0: {}
estraverse@5.3.0: {}
@@ -11185,6 +12666,14 @@ snapshots:
fast-deep-equal@3.1.3: {}
+ fast-glob@3.3.1:
+ dependencies:
+ "@nodelib/fs.stat": 2.0.5
+ "@nodelib/fs.walk": 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.8
+
fast-glob@3.3.3:
dependencies:
"@nodelib/fs.stat": 2.0.5
@@ -11199,8 +12688,7 @@ snapshots:
fast-safe-stringify@2.1.1: {}
- fast-uri@3.1.0:
- optional: true
+ fast-uri@3.1.0: {}
fastq@1.19.1:
dependencies:
@@ -11262,6 +12750,8 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
+ forwarded-parse@2.1.2: {}
+
fraction.js@4.3.7: {}
fs.realpath@1.0.0: {}
@@ -11330,16 +12820,7 @@ snapshots:
dependencies:
is-glob: 4.0.3
- glob-to-regexp@0.4.1:
- optional: true
-
- glob@10.3.10:
- dependencies:
- foreground-child: 3.3.1
- jackspeak: 2.3.6
- minimatch: 9.0.5
- minipass: 7.1.2
- path-scurry: 1.11.1
+ glob-to-regexp@0.4.1: {}
glob@10.4.5:
dependencies:
@@ -11359,13 +12840,12 @@ snapshots:
once: 1.4.0
path-is-absolute: 1.0.1
- glob@8.1.0:
+ glob@9.3.5:
dependencies:
fs.realpath: 1.0.0
- inflight: 1.0.6
- inherits: 2.0.4
- minimatch: 5.1.6
- once: 1.4.0
+ minimatch: 8.0.4
+ minipass: 4.2.8
+ path-scurry: 1.11.1
globals@14.0.0: {}
@@ -11482,8 +12962,6 @@ snapshots:
ignore@7.0.5: {}
- immediate@3.0.6: {}
-
immer@10.1.1: {}
immutable@5.1.3: {}
@@ -11493,6 +12971,13 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
+ import-in-the-middle@1.14.2:
+ dependencies:
+ acorn: 8.15.0
+ acorn-import-attributes: 1.9.5(acorn@8.15.0)
+ cjs-module-lexer: 1.4.3
+ module-details-from-path: 1.0.4
+
import-local@3.2.0:
dependencies:
pkg-dir: 4.2.0
@@ -11557,6 +13042,9 @@ snapshots:
is-arrayish@0.2.1: {}
+ is-arrayish@0.3.2:
+ optional: true
+
is-async-function@2.1.1:
dependencies:
async-function: 1.0.0
@@ -11728,12 +13216,6 @@ snapshots:
has-symbols: 1.1.0
set-function-name: 2.0.2
- jackspeak@2.3.6:
- dependencies:
- "@isaacs/cliui": 8.0.2
- optionalDependencies:
- "@pkgjs/parseargs": 0.11.0
-
jackspeak@3.4.3:
dependencies:
"@isaacs/cliui": 8.0.2
@@ -12042,10 +13524,9 @@ snapshots:
jest-worker@27.5.1:
dependencies:
- "@types/node": 24.2.1
+ "@types/node": 24.3.1
merge-stream: 2.0.0
supports-color: 8.1.1
- optional: true
jest-worker@29.7.0:
dependencies:
@@ -12136,10 +13617,6 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
- lie@3.1.1:
- dependencies:
- immediate: 3.0.6
-
lighterhtml@4.2.0:
dependencies:
"@ungap/create-content": 0.2.0
@@ -12156,12 +13633,7 @@ snapshots:
lines-and-columns@1.2.4: {}
- loader-runner@4.3.0:
- optional: true
-
- localforage@1.10.0:
- dependencies:
- lie: 3.1.1
+ loader-runner@4.3.0: {}
locate-path@5.0.0:
dependencies:
@@ -12199,7 +13671,11 @@ snapshots:
dependencies:
react: 18.3.1
- magic-string@0.27.0:
+ magic-string@0.30.19:
+ dependencies:
+ "@jridgewell/sourcemap-codec": 1.5.5
+
+ magic-string@0.30.8:
dependencies:
"@jridgewell/sourcemap-codec": 1.5.5
@@ -12478,19 +13954,23 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
+ minimatch@8.0.4:
+ dependencies:
+ brace-expansion: 2.0.2
+
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
minimist@1.2.8: {}
+ minipass@4.2.8: {}
+
minipass@7.1.2: {}
mitt@3.0.1: {}
- mkdirp@0.5.6:
- dependencies:
- minimist: 1.2.8
+ module-details-from-path@1.0.4: {}
ms@2.1.3: {}
@@ -12508,13 +13988,13 @@ snapshots:
neo-async@2.6.2: {}
- next-auth@4.24.11(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
+ next-auth@4.24.11(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
"@babel/runtime": 7.28.2
"@panva/hkdf": 1.2.1
cookie: 0.7.2
jose: 4.15.9
- next: 14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ next: 15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.27.0
@@ -12528,29 +14008,27 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0):
+ next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0):
dependencies:
- "@next/env": 14.2.31
- "@swc/helpers": 0.5.5
- busboy: 1.6.0
+ "@next/env": 15.5.3
+ "@swc/helpers": 0.5.15
caniuse-lite: 1.0.30001734
- graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
- styled-jsx: 5.1.1(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1)
+ styled-jsx: 5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1)
optionalDependencies:
- "@next/swc-darwin-arm64": 14.2.31
- "@next/swc-darwin-x64": 14.2.31
- "@next/swc-linux-arm64-gnu": 14.2.31
- "@next/swc-linux-arm64-musl": 14.2.31
- "@next/swc-linux-x64-gnu": 14.2.31
- "@next/swc-linux-x64-musl": 14.2.31
- "@next/swc-win32-arm64-msvc": 14.2.31
- "@next/swc-win32-ia32-msvc": 14.2.31
- "@next/swc-win32-x64-msvc": 14.2.31
+ "@next/swc-darwin-arm64": 15.5.3
+ "@next/swc-darwin-x64": 15.5.3
+ "@next/swc-linux-arm64-gnu": 15.5.3
+ "@next/swc-linux-arm64-musl": 15.5.3
+ "@next/swc-linux-x64-gnu": 15.5.3
+ "@next/swc-linux-x64-musl": 15.5.3
+ "@next/swc-win32-arm64-msvc": 15.5.3
+ "@next/swc-win32-x64-msvc": 15.5.3
"@opentelemetry/api": 1.9.0
sass: 1.90.0
+ sharp: 0.34.3
transitivePeerDependencies:
- "@babel/core"
- babel-plugin-macros
@@ -12576,12 +14054,12 @@ snapshots:
dependencies:
path-key: 3.1.1
- nuqs@2.4.3(next@14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1):
+ nuqs@2.4.3(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1):
dependencies:
mitt: 3.0.1
react: 18.3.1
optionalDependencies:
- next: 14.2.31(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
+ next: 15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0)
oauth@0.9.15: {}
@@ -12749,6 +14227,18 @@ snapshots:
perfect-freehand@1.2.2: {}
+ pg-int8@1.0.1: {}
+
+ pg-protocol@1.10.3: {}
+
+ pg-types@2.2.0:
+ dependencies:
+ pg-int8: 1.0.1
+ postgres-array: 2.0.0
+ postgres-bytea: 1.0.0
+ postgres-date: 1.0.7
+ postgres-interval: 1.2.0
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -12811,6 +14301,16 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
+ postgres-array@2.0.0: {}
+
+ postgres-bytea@1.0.0: {}
+
+ postgres-date@1.0.7: {}
+
+ postgres-interval@1.2.0:
+ dependencies:
+ xtend: 4.0.2
+
preact-render-to-string@5.2.6(preact@10.27.0):
dependencies:
preact: 10.27.0
@@ -13014,6 +14514,14 @@ snapshots:
require-from-string@2.0.2: {}
+ require-in-the-middle@7.5.2:
+ dependencies:
+ debug: 4.4.1(supports-color@9.4.0)
+ module-details-from-path: 1.0.4
+ resolve: 1.22.10
+ transitivePeerDependencies:
+ - supports-color
+
reraf@1.1.1: {}
reselect@5.1.1: {}
@@ -13048,8 +14556,31 @@ snapshots:
reusify@1.1.0: {}
- rollup@2.78.0:
+ rollup@4.50.1:
+ dependencies:
+ "@types/estree": 1.0.8
optionalDependencies:
+ "@rollup/rollup-android-arm-eabi": 4.50.1
+ "@rollup/rollup-android-arm64": 4.50.1
+ "@rollup/rollup-darwin-arm64": 4.50.1
+ "@rollup/rollup-darwin-x64": 4.50.1
+ "@rollup/rollup-freebsd-arm64": 4.50.1
+ "@rollup/rollup-freebsd-x64": 4.50.1
+ "@rollup/rollup-linux-arm-gnueabihf": 4.50.1
+ "@rollup/rollup-linux-arm-musleabihf": 4.50.1
+ "@rollup/rollup-linux-arm64-gnu": 4.50.1
+ "@rollup/rollup-linux-arm64-musl": 4.50.1
+ "@rollup/rollup-linux-loongarch64-gnu": 4.50.1
+ "@rollup/rollup-linux-ppc64-gnu": 4.50.1
+ "@rollup/rollup-linux-riscv64-gnu": 4.50.1
+ "@rollup/rollup-linux-riscv64-musl": 4.50.1
+ "@rollup/rollup-linux-s390x-gnu": 4.50.1
+ "@rollup/rollup-linux-x64-gnu": 4.50.1
+ "@rollup/rollup-linux-x64-musl": 4.50.1
+ "@rollup/rollup-openharmony-arm64": 4.50.1
+ "@rollup/rollup-win32-arm64-msvc": 4.50.1
+ "@rollup/rollup-win32-ia32-msvc": 4.50.1
+ "@rollup/rollup-win32-x64-msvc": 4.50.1
fsevents: 2.3.3
rtcstats@https://codeload.github.com/whereby/rtcstats/tar.gz/63bcb6420d76d34161b39e494524ae73aa6dd70d:
@@ -13100,7 +14631,6 @@ snapshots:
ajv: 8.17.1
ajv-formats: 2.1.1(ajv@8.17.1)
ajv-keywords: 5.1.0(ajv@8.17.1)
- optional: true
sdp-transform@2.15.0: {}
@@ -13113,7 +14643,6 @@ snapshots:
serialize-javascript@6.0.2:
dependencies:
randombytes: 2.1.0
- optional: true
set-function-length@1.2.2:
dependencies:
@@ -13137,12 +14666,44 @@ snapshots:
es-errors: 1.3.0
es-object-atoms: 1.1.1
+ sharp@0.34.3:
+ dependencies:
+ color: 4.2.3
+ detect-libc: 2.0.4
+ semver: 7.7.2
+ optionalDependencies:
+ "@img/sharp-darwin-arm64": 0.34.3
+ "@img/sharp-darwin-x64": 0.34.3
+ "@img/sharp-libvips-darwin-arm64": 1.2.0
+ "@img/sharp-libvips-darwin-x64": 1.2.0
+ "@img/sharp-libvips-linux-arm": 1.2.0
+ "@img/sharp-libvips-linux-arm64": 1.2.0
+ "@img/sharp-libvips-linux-ppc64": 1.2.0
+ "@img/sharp-libvips-linux-s390x": 1.2.0
+ "@img/sharp-libvips-linux-x64": 1.2.0
+ "@img/sharp-libvips-linuxmusl-arm64": 1.2.0
+ "@img/sharp-libvips-linuxmusl-x64": 1.2.0
+ "@img/sharp-linux-arm": 0.34.3
+ "@img/sharp-linux-arm64": 0.34.3
+ "@img/sharp-linux-ppc64": 0.34.3
+ "@img/sharp-linux-s390x": 0.34.3
+ "@img/sharp-linux-x64": 0.34.3
+ "@img/sharp-linuxmusl-arm64": 0.34.3
+ "@img/sharp-linuxmusl-x64": 0.34.3
+ "@img/sharp-wasm32": 0.34.3
+ "@img/sharp-win32-arm64": 0.34.3
+ "@img/sharp-win32-ia32": 0.34.3
+ "@img/sharp-win32-x64": 0.34.3
+ optional: true
+
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
+ shimmer@1.2.1: {}
+
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -13187,6 +14748,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ simple-swizzle@0.2.2:
+ dependencies:
+ is-arrayish: 0.3.2
+ optional: true
+
slash@3.0.0: {}
socket.io-client@4.7.2:
@@ -13218,7 +14784,6 @@ snapshots:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
- optional: true
source-map@0.5.7: {}
@@ -13247,8 +14812,6 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
- streamsearch@1.1.0: {}
-
string-length@4.0.2:
dependencies:
char-regex: 1.0.2
@@ -13349,7 +14912,7 @@ snapshots:
dependencies:
inline-style-parser: 0.2.4
- styled-jsx@5.1.1(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1):
+ styled-jsx@5.1.6(@babel/core@7.28.3)(babel-plugin-macros@3.1.0)(react@18.3.1):
dependencies:
client-only: 0.0.1
react: 18.3.1
@@ -13414,18 +14977,16 @@ snapshots:
transitivePeerDependencies:
- ts-node
- tapable@2.2.3:
- optional: true
+ tapable@2.2.3: {}
terser-webpack-plugin@5.3.14(webpack@5.101.3):
dependencies:
- "@jridgewell/trace-mapping": 0.3.30
+ "@jridgewell/trace-mapping": 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.2
serialize-javascript: 6.0.2
terser: 5.44.0
webpack: 5.101.3
- optional: true
terser@5.44.0:
dependencies:
@@ -13433,7 +14994,6 @@ snapshots:
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
- optional: true
test-exclude@6.0.0:
dependencies:
@@ -13638,6 +15198,13 @@ snapshots:
unist-util-is: 6.0.0
unist-util-visit-parents: 6.0.1
+ unplugin@1.0.1:
+ dependencies:
+ acorn: 8.15.0
+ chokidar: 3.6.0
+ webpack-sources: 3.3.3
+ webpack-virtual-modules: 0.5.0
+
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.3
@@ -13730,7 +15297,6 @@ snapshots:
dependencies:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
- optional: true
wavesurfer.js@7.10.1: {}
@@ -13738,6 +15304,8 @@ snapshots:
webpack-sources@3.3.3: {}
+ webpack-virtual-modules@0.5.0: {}
+
webpack@5.101.3:
dependencies:
"@types/eslint-scope": 3.7.7
@@ -13769,7 +15337,6 @@ snapshots:
- "@swc/core"
- esbuild
- uglify-js
- optional: true
webrtc-adapter@9.0.3:
dependencies:
@@ -13852,6 +15419,8 @@ snapshots:
xmlhttprequest-ssl@2.0.0: {}
+ xtend@4.0.2: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
From 79f161436e53392389653434cfda98858c8bd552 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Fri, 12 Sep 2025 13:07:58 -0400
Subject: [PATCH 29/77] chore: meeting user id removal and room id requirement
(#635)
* chore: remove meeting user id and make meeting room id required
* meeting room_id optional
* orphaned meeting room ids DATA migration
* ci fix
* fix meeting_room_id_fkey downgrade
* fix migration rollback
* fix: put index back (meeting room id)
* fix: put index back (meeting room id)
* fix: put index back (meeting room id)
* remove noop migrations
---------
Co-authored-by: Igor Loskutov
---
...da2ee_remove_user_id_from_meeting_table.py | 36 ++++++++++++++++++
...lean_up_orphaned_room_id_references_in_.py | 32 ++++++++++++++++
..._make_meeting_room_id_required_and_add_.py | 38 +++++++++++++++++++
...make_meeting_room_id_nullable_but_keep_.py | 34 +++++++++++++++++
server/reflector/db/meetings.py | 34 +++++------------
server/reflector/views/rooms.py | 1 -
server/tests/test_cleanup.py | 2 -
7 files changed, 150 insertions(+), 27 deletions(-)
create mode 100644 server/migrations/versions/0ce521cda2ee_remove_user_id_from_meeting_table.py
create mode 100644 server/migrations/versions/2ae3db106d4e_clean_up_orphaned_room_id_references_in_.py
create mode 100644 server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py
create mode 100644 server/migrations/versions/def1b5867d4c_make_meeting_room_id_nullable_but_keep_.py
diff --git a/server/migrations/versions/0ce521cda2ee_remove_user_id_from_meeting_table.py b/server/migrations/versions/0ce521cda2ee_remove_user_id_from_meeting_table.py
new file mode 100644
index 00000000..2e76e8a6
--- /dev/null
+++ b/server/migrations/versions/0ce521cda2ee_remove_user_id_from_meeting_table.py
@@ -0,0 +1,36 @@
+"""remove user_id from meeting table
+
+Revision ID: 0ce521cda2ee
+Revises: 6dec9fb5b46c
+Create Date: 2025-09-10 12:40:55.688899
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "0ce521cda2ee"
+down_revision: Union[str, None] = "6dec9fb5b46c"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.drop_column("user_id")
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column("user_id", sa.VARCHAR(), autoincrement=False, nullable=True)
+ )
+
+ # ### end Alembic commands ###
diff --git a/server/migrations/versions/2ae3db106d4e_clean_up_orphaned_room_id_references_in_.py b/server/migrations/versions/2ae3db106d4e_clean_up_orphaned_room_id_references_in_.py
new file mode 100644
index 00000000..c091ab49
--- /dev/null
+++ b/server/migrations/versions/2ae3db106d4e_clean_up_orphaned_room_id_references_in_.py
@@ -0,0 +1,32 @@
+"""clean up orphaned room_id references in meeting table
+
+Revision ID: 2ae3db106d4e
+Revises: def1b5867d4c
+Create Date: 2025-09-11 10:35:15.759967
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "2ae3db106d4e"
+down_revision: Union[str, None] = "def1b5867d4c"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Set room_id to NULL for meetings that reference non-existent rooms
+ op.execute("""
+ UPDATE meeting
+ SET room_id = NULL
+ WHERE room_id IS NOT NULL
+ AND room_id NOT IN (SELECT id FROM room WHERE id IS NOT NULL)
+ """)
+
+
+def downgrade() -> None:
+ # Cannot restore orphaned references - no operation needed
+ pass
diff --git a/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py b/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py
new file mode 100644
index 00000000..20828c65
--- /dev/null
+++ b/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py
@@ -0,0 +1,38 @@
+"""make meeting room_id required and add foreign key
+
+Revision ID: 6dec9fb5b46c
+Revises: 61882a919591
+Create Date: 2025-09-10 10:47:06.006819
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "6dec9fb5b46c"
+down_revision: Union[str, None] = "61882a919591"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=False)
+ batch_op.create_foreign_key(
+ None, "room", ["room_id"], ["id"], ondelete="CASCADE"
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.drop_constraint("meeting_room_id_fkey", type_="foreignkey")
+ batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=True)
+
+ # ### end Alembic commands ###
diff --git a/server/migrations/versions/def1b5867d4c_make_meeting_room_id_nullable_but_keep_.py b/server/migrations/versions/def1b5867d4c_make_meeting_room_id_nullable_but_keep_.py
new file mode 100644
index 00000000..982bea27
--- /dev/null
+++ b/server/migrations/versions/def1b5867d4c_make_meeting_room_id_nullable_but_keep_.py
@@ -0,0 +1,34 @@
+"""make meeting room_id nullable but keep foreign key
+
+Revision ID: def1b5867d4c
+Revises: 0ce521cda2ee
+Create Date: 2025-09-11 09:42:18.697264
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "def1b5867d4c"
+down_revision: Union[str, None] = "0ce521cda2ee"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=True)
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=False)
+
+ # ### end Alembic commands ###
diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py
index bb7366b1..c3821241 100644
--- a/server/reflector/db/meetings.py
+++ b/server/reflector/db/meetings.py
@@ -17,8 +17,12 @@ meetings = sa.Table(
sa.Column("host_room_url", sa.String),
sa.Column("start_date", sa.DateTime(timezone=True)),
sa.Column("end_date", sa.DateTime(timezone=True)),
- sa.Column("user_id", sa.String),
- sa.Column("room_id", sa.String),
+ sa.Column(
+ "room_id",
+ sa.String,
+ sa.ForeignKey("room.id", ondelete="CASCADE"),
+ nullable=True,
+ ),
sa.Column("is_locked", sa.Boolean, nullable=False, server_default=sa.false()),
sa.Column("room_mode", sa.String, nullable=False, server_default="normal"),
sa.Column("recording_type", sa.String, nullable=False, server_default="cloud"),
@@ -80,8 +84,7 @@ class Meeting(BaseModel):
host_room_url: str
start_date: datetime
end_date: datetime
- user_id: str | None = None
- room_id: str | None = None
+ room_id: str | None
is_locked: bool = False
room_mode: Literal["normal", "group"] = "normal"
recording_type: Literal["none", "local", "cloud"] = "cloud"
@@ -100,12 +103,8 @@ class MeetingController:
host_room_url: str,
start_date: datetime,
end_date: datetime,
- user_id: str,
room: Room,
):
- """
- Create a new meeting
- """
meeting = Meeting(
id=id,
room_name=room_name,
@@ -113,7 +112,6 @@ class MeetingController:
host_room_url=host_room_url,
start_date=start_date,
end_date=end_date,
- user_id=user_id,
room_id=room.id,
is_locked=room.is_locked,
room_mode=room.room_mode,
@@ -125,19 +123,13 @@ class MeetingController:
return meeting
async def get_all_active(self) -> list[Meeting]:
- """
- Get active meetings.
- """
query = meetings.select().where(meetings.c.is_active)
return await get_database().fetch_all(query)
async def get_by_room_name(
self,
room_name: str,
- ) -> Meeting:
- """
- Get a meeting by room name.
- """
+ ) -> Meeting | None:
query = meetings.select().where(meetings.c.room_name == room_name)
result = await get_database().fetch_one(query)
if not result:
@@ -145,10 +137,7 @@ class MeetingController:
return Meeting(**result)
- async def get_active(self, room: Room, current_time: datetime) -> Meeting:
- """
- Get latest active meeting for a room.
- """
+ async def get_active(self, room: Room, current_time: datetime) -> Meeting | None:
end_date = getattr(meetings.c, "end_date")
query = (
meetings.select()
@@ -168,9 +157,6 @@ class MeetingController:
return Meeting(**result)
async def get_by_id(self, meeting_id: str, **kwargs) -> Meeting | None:
- """
- Get a meeting by id
- """
query = meetings.select().where(meetings.c.id == meeting_id)
result = await get_database().fetch_one(query)
if not result:
@@ -201,7 +187,7 @@ class MeetingConsentController:
result = await get_database().fetch_one(query)
if result is None:
return None
- return MeetingConsent(**result) if result else None
+ return MeetingConsent(**result)
async def upsert(self, consent: MeetingConsent) -> MeetingConsent:
"""Create new consent or update existing one for authenticated users"""
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index 38b611d6..546c1dd3 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -209,7 +209,6 @@ async def rooms_create_meeting(
host_room_url=whereby_meeting["hostRoomUrl"],
start_date=parse_datetime_with_timezone(whereby_meeting["startDate"]),
end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]),
- user_id=user_id,
room=room,
)
except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError):
diff --git a/server/tests/test_cleanup.py b/server/tests/test_cleanup.py
index 3c5149ae..2cb8614c 100644
--- a/server/tests/test_cleanup.py
+++ b/server/tests/test_cleanup.py
@@ -105,7 +105,6 @@ async def test_cleanup_deletes_associated_meeting_and_recording():
host_room_url="https://example.com/meeting-host",
start_date=old_date,
end_date=old_date + timedelta(hours=1),
- user_id=None,
room_id=None,
)
)
@@ -241,7 +240,6 @@ async def test_meeting_consent_cascade_delete():
host_room_url="https://example.com/cascade-test-host",
start_date=datetime.now(timezone.utc),
end_date=datetime.now(timezone.utc) + timedelta(hours=1),
- user_id="test-user",
room_id=None,
)
)
From 5f143fe3640875dcb56c26694254a93189281d17 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Mon, 15 Sep 2025 10:49:20 -0600
Subject: [PATCH 30/77] fix: zulip and consent handler on the file pipeline
(#645)
---
.../reflector/pipelines/main_file_pipeline.py | 46 +++++++++++++------
1 file changed, 32 insertions(+), 14 deletions(-)
diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py
index 5c57dddb..ce9d000e 100644
--- a/server/reflector/pipelines/main_file_pipeline.py
+++ b/server/reflector/pipelines/main_file_pipeline.py
@@ -12,7 +12,7 @@ from pathlib import Path
import av
import structlog
-from celery import shared_task
+from celery import chain, shared_task
from reflector.asynctask import asynctask
from reflector.db.rooms import rooms_controller
@@ -26,6 +26,8 @@ from reflector.logger import logger
from reflector.pipelines.main_live_pipeline import (
PipelineMainBase,
broadcast_to_sockets,
+ task_cleanup_consent,
+ task_pipeline_post_to_zulip,
)
from reflector.processors import (
AudioFileWriterProcessor,
@@ -379,6 +381,28 @@ class PipelineMainFile(PipelineMainBase):
await processor.flush()
+@shared_task
+@asynctask
+async def task_send_webhook_if_needed(*, transcript_id: str):
+ """Send webhook if this is a room recording with webhook configured"""
+ transcript = await transcripts_controller.get_by_id(transcript_id)
+ if not transcript:
+ return
+
+ if transcript.source_kind == SourceKind.ROOM and transcript.room_id:
+ room = await rooms_controller.get_by_id(transcript.room_id)
+ if room and room.webhook_url:
+ logger.info(
+ "Dispatching webhook",
+ transcript_id=transcript_id,
+ room_id=room.id,
+ webhook_url=room.webhook_url,
+ )
+ send_transcript_webhook.delay(
+ transcript_id, room.id, event_id=uuid.uuid4().hex
+ )
+
+
@shared_task
@asynctask
async def task_pipeline_file_process(*, transcript_id: str):
@@ -406,16 +430,10 @@ async def task_pipeline_file_process(*, transcript_id: str):
await pipeline.set_status(transcript_id, "error")
raise
- # Trigger webhook if this is a room recording with webhook configured
- if transcript.source_kind == SourceKind.ROOM and transcript.room_id:
- room = await rooms_controller.get_by_id(transcript.room_id)
- if room and room.webhook_url:
- logger.info(
- "Dispatching webhook task",
- transcript_id=transcript_id,
- room_id=room.id,
- webhook_url=room.webhook_url,
- )
- send_transcript_webhook.delay(
- transcript_id, room.id, event_id=uuid.uuid4().hex
- )
+ # Run post-processing chain: consent cleanup -> zulip -> webhook
+ post_chain = chain(
+ task_cleanup_consent.si(transcript_id=transcript_id),
+ task_pipeline_post_to_zulip.si(transcript_id=transcript_id),
+ task_send_webhook_if_needed.si(transcript_id=transcript_id),
+ )
+ post_chain.delay()
From 3f1fe8c9bf0a63f26555b6bdf22f73846d3f88b1 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Mon, 15 Sep 2025 14:19:10 -0400
Subject: [PATCH 31/77] chore: remove timeout-based auth session logic (#649)
* remove timeout-based auth session logic
* remove timeout-based auth session logic
---------
Co-authored-by: Igor Loskutov
---
www/app/lib/apiClient.tsx | 40 ++++++++++++++++++++-------------------
www/app/lib/types.ts | 10 ++++++----
2 files changed, 27 insertions(+), 23 deletions(-)
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
index 86f8f161..a5cec06b 100644
--- a/www/app/lib/apiClient.tsx
+++ b/www/app/lib/apiClient.tsx
@@ -3,8 +3,10 @@
import createClient from "openapi-fetch";
import type { paths } from "../reflector-api";
import createFetchClient from "openapi-react-query";
-import { assertExistsAndNonEmptyString } from "./utils";
+import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
+import { getSession } from "next-auth/react";
+import { assertExtendedToken } from "./types";
export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(
@@ -21,29 +23,29 @@ export const client = createClient({
baseUrl: API_URL,
});
-const waitForAuthTokenDefinitivePresenceOrAbscence = async () => {
- let tries = 0;
- let time = 0;
- const STEP = 100;
- while (currentAuthToken === undefined) {
- await new Promise((resolve) => setTimeout(resolve, STEP));
- time += STEP;
- tries++;
- // most likely first try is more than enough, if it's more there's already something weird happens
- if (tries > 10) {
- // even when there's no auth assumed at all, we probably should explicitly call configureApiAuth(null)
- throw new Error(
- `Could not get auth token definitive presence/absence in ${time}ms. not calling configureApiAuth?`,
- );
- }
+// will assert presence/absence of login initially
+const initialSessionPromise = getSession();
+
+const waitForAuthTokenDefinitivePresenceOrAbsence = async () => {
+ const initialSession = await initialSessionPromise;
+ if (currentAuthToken === undefined) {
+ currentAuthToken =
+ initialSession === null
+ ? null
+ : assertExtendedToken(initialSession).accessToken;
}
+ // otherwise already overwritten by external forces
+ return currentAuthToken;
};
client.use({
async onRequest({ request }) {
- await waitForAuthTokenDefinitivePresenceOrAbscence();
- if (currentAuthToken) {
- request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
+ const token = await waitForAuthTokenDefinitivePresenceOrAbsence();
+ if (token !== null) {
+ request.headers.set(
+ "Authorization",
+ `Bearer ${parseNonEmptyString(token)}`,
+ );
}
// XXX Only set Content-Type if not already set (FormData will set its own boundary)
// This is a work around for uploading file, we're passing a formdata
diff --git a/www/app/lib/types.ts b/www/app/lib/types.ts
index af5625ec..7bcb522b 100644
--- a/www/app/lib/types.ts
+++ b/www/app/lib/types.ts
@@ -21,7 +21,7 @@ export interface CustomSession extends Session {
// assumption that JWT is JWTWithAccessToken - we set it in jwt callback of auth; typing isn't strong around there
// but the assumption is crucial to auth working
export const assertExtendedToken = (
- t: T,
+ t: Exclude,
): T & {
accessTokenExpires: number;
accessToken: string;
@@ -45,7 +45,7 @@ export const assertExtendedToken = (
};
export const assertExtendedTokenAndUserId = (
- t: T,
+ t: Exclude,
): T & {
accessTokenExpires: number;
accessToken: string;
@@ -55,7 +55,7 @@ export const assertExtendedTokenAndUserId = (
} => {
const extendedToken = assertExtendedToken(t);
if (typeof (extendedToken.user as any)?.id === "string") {
- return t as T & {
+ return t as Exclude & {
accessTokenExpires: number;
accessToken: string;
user: U & {
@@ -67,7 +67,9 @@ export const assertExtendedTokenAndUserId = (
};
// best attempt to check the session is valid
-export const assertCustomSession = (s: S): CustomSession => {
+export const assertCustomSession = (
+ s: Exclude,
+): CustomSession => {
const r = assertExtendedTokenAndUserId(s);
// no other checks for now
return r as CustomSession;
From c546e69739e68bb74fbc877eb62609928e5b8de6 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Mon, 15 Sep 2025 12:34:51 -0600
Subject: [PATCH 32/77] fix: zulip stream and topic selection in share dialog
(#644)
* fix: zulip stream and topic selection in share dialog
Replace useListCollection with createListCollection to match the working
room edit implementation. This ensures collections update when data loads,
fixing the issue where streams and topics wouldn't appear until navigation.
* fix: wrap createListCollection in useMemo to prevent recreation on every render
Both streamCollection and topicCollection are now memoized to improve performance
and prevent unnecessary re-renders of Combobox components
---
www/app/(app)/transcripts/shareZulip.tsx | 43 +++++++++++-------------
1 file changed, 19 insertions(+), 24 deletions(-)
diff --git a/www/app/(app)/transcripts/shareZulip.tsx b/www/app/(app)/transcripts/shareZulip.tsx
index 5cee16c1..bee14822 100644
--- a/www/app/(app)/transcripts/shareZulip.tsx
+++ b/www/app/(app)/transcripts/shareZulip.tsx
@@ -14,8 +14,7 @@ import {
Checkbox,
Combobox,
Spinner,
- useFilter,
- useListCollection,
+ createListCollection,
} from "@chakra-ui/react";
import { TbBrandZulip } from "react-icons/tb";
import {
@@ -48,8 +47,6 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
const { data: topics = [] } = useZulipTopics(selectedStreamId);
const postToZulipMutation = useTranscriptPostToZulip();
- const { contains } = useFilter({ sensitivity: "base" });
-
const streamItems = useMemo(() => {
return streams.map((stream: Stream) => ({
label: stream.name,
@@ -64,17 +61,21 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
}));
}, [topics]);
- const { collection: streamItemsCollection, filter: streamItemsFilter } =
- useListCollection({
- initialItems: streamItems,
- filter: contains,
- });
+ const streamCollection = useMemo(
+ () =>
+ createListCollection({
+ items: streamItems,
+ }),
+ [streamItems],
+ );
- const { collection: topicItemsCollection, filter: topicItemsFilter } =
- useListCollection({
- initialItems: topicItems,
- filter: contains,
- });
+ const topicCollection = useMemo(
+ () =>
+ createListCollection({
+ items: topicItems,
+ }),
+ [topicItems],
+ );
// Update selected stream ID when stream changes
useEffect(() => {
@@ -156,15 +157,12 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
#
{
setTopic(undefined);
setStream(e.value[0]);
}}
- onInputValueChange={(e) =>
- streamItemsFilter(e.inputValue)
- }
openOnClick={true}
positioning={{
strategy: "fixed",
@@ -181,7 +179,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
No streams found
- {streamItemsCollection.items.map((item) => (
+ {streamItems.map((item) => (
{item.label}
@@ -197,12 +195,9 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
#
setTopic(e.value[0])}
- onInputValueChange={(e) =>
- topicItemsFilter(e.inputValue)
- }
openOnClick
selectionBehavior="replace"
skipAnimationOnMount={true}
@@ -222,7 +217,7 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
No topics found
- {topicItemsCollection.items.map((item) => (
+ {topicItems.map((item) => (
{item.label}
From b42f7cfc606783afcee792590efcc78b507468ab Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Mon, 15 Sep 2025 18:19:19 -0600
Subject: [PATCH 33/77] feat: remove profanity filter that was there for
conference (#652)
---
server/pyproject.toml | 1 -
server/reflector/processors/types.py | 26 ++-------------
server/uv.lock | 47 +++++++---------------------
3 files changed, 14 insertions(+), 60 deletions(-)
diff --git a/server/pyproject.toml b/server/pyproject.toml
index 47d314d9..d055f461 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -27,7 +27,6 @@ dependencies = [
"prometheus-fastapi-instrumentator>=6.1.0",
"sentencepiece>=0.1.99",
"protobuf>=4.24.3",
- "profanityfilter>=2.0.6",
"celery>=5.3.4",
"redis>=5.0.1",
"python-jose[cryptography]>=3.3.0",
diff --git a/server/reflector/processors/types.py b/server/reflector/processors/types.py
index 480086af..7096e81c 100644
--- a/server/reflector/processors/types.py
+++ b/server/reflector/processors/types.py
@@ -4,11 +4,8 @@ import tempfile
from pathlib import Path
from typing import Annotated, TypedDict
-from profanityfilter import ProfanityFilter
from pydantic import BaseModel, Field, PrivateAttr
-from reflector.redis_cache import redis_cache
-
class DiarizationSegment(TypedDict):
"""Type definition for diarization segment containing speaker information"""
@@ -20,9 +17,6 @@ class DiarizationSegment(TypedDict):
PUNC_RE = re.compile(r"[.;:?!…]")
-profanity_filter = ProfanityFilter()
-profanity_filter.set_censor("*")
-
class AudioFile(BaseModel):
name: str
@@ -124,21 +118,11 @@ def words_to_segments(words: list[Word]) -> list[TranscriptSegment]:
class Transcript(BaseModel):
translation: str | None = None
- words: list[Word] = None
-
- @property
- def raw_text(self):
- # Uncensored text
- return "".join([word.text for word in self.words])
-
- @redis_cache(prefix="profanity", duration=3600 * 24 * 7)
- def _get_censored_text(self, text: str):
- return profanity_filter.censor(text).strip()
+ words: list[Word] = []
@property
def text(self):
- # Censored text
- return self._get_censored_text(self.raw_text)
+ return "".join([word.text for word in self.words])
@property
def human_timestamp(self):
@@ -170,12 +154,6 @@ class Transcript(BaseModel):
word.start += offset
word.end += offset
- def clone(self):
- words = [
- Word(text=word.text, start=word.start, end=word.end) for word in self.words
- ]
- return Transcript(text=self.text, translation=self.translation, words=words)
-
def as_segments(self) -> list[TranscriptSegment]:
return words_to_segments(self.words)
diff --git a/server/uv.lock b/server/uv.lock
index 5604f922..b93d0ac3 100644
--- a/server/uv.lock
+++ b/server/uv.lock
@@ -1325,15 +1325,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
]
-[[package]]
-name = "inflection"
-version = "0.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" },
-]
-
[[package]]
name = "iniconfig"
version = "2.1.0"
@@ -2311,18 +2302,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload-time = "2018-05-29T17:18:17.53Z" },
]
-[[package]]
-name = "profanityfilter"
-version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "inflection" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/8d/03/08740b5e0800f9eb9f675c149a497a3f3735e7b04e414bcce64136e7e487/profanityfilter-2.1.0.tar.gz", hash = "sha256:0ede04e92a9d7255faa52b53776518edc6586dda828aca677c74b5994dfdd9d8", size = 7910, upload-time = "2024-11-25T22:31:51.194Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/88/03/eb18f72dc6e6398e75e3762677f18ab3a773a384b18efd3ed9119844e892/profanityfilter-2.1.0-py2.py3-none-any.whl", hash = "sha256:e1bc07012760fd74512a335abb93a36877831ed26abab78bfe31bebb68f8c844", size = 7483, upload-time = "2024-11-25T22:31:50.129Z" },
-]
-
[[package]]
name = "prometheus-client"
version = "0.22.1"
@@ -3131,7 +3110,6 @@ dependencies = [
{ name = "loguru" },
{ name = "nltk" },
{ name = "openai" },
- { name = "profanityfilter" },
{ name = "prometheus-fastapi-instrumentator" },
{ name = "protobuf" },
{ name = "psycopg2-binary" },
@@ -3208,7 +3186,6 @@ requires-dist = [
{ name = "loguru", specifier = ">=0.7.0" },
{ name = "nltk", specifier = ">=3.8.1" },
{ name = "openai", specifier = ">=1.59.7" },
- { name = "profanityfilter", specifier = ">=2.0.6" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=6.1.0" },
{ name = "protobuf", specifier = ">=4.24.3" },
{ name = "psycopg2-binary", specifier = ">=2.9.10" },
@@ -3954,8 +3931,8 @@ dependencies = [
{ name = "typing-extensions", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" },
]
wheels = [
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:3d05017d19bc99741288e458888283a44b0ee881d53f05f72f8b1cfea8998122" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:a47b7986bee3f61ad217d8a8ce24605809ab425baf349f97de758815edd2ef54" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp311-none-macosx_11_0_arm64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl" },
]
[[package]]
@@ -3980,16 +3957,16 @@ dependencies = [
{ name = "typing-extensions", marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" },
]
wheels = [
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:2bfc013dd6efdc8f8223a0241d3529af9f315dffefb53ffa3bf14d3f10127da6" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:680129efdeeec3db5da3f88ee5d28c1b1e103b774aef40f9d638e2cce8f8d8d8" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:cb06175284673a581dd91fb1965662ae4ecaba6e5c357aa0ea7bb8b84b6b7eeb" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_amd64.whl", hash = "sha256:7631ef49fbd38d382909525b83696dc12a55d68492ade4ace3883c62b9fc140f" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_arm64.whl", hash = "sha256:41e6fc5ec0914fcdce44ccf338b1d19a441b55cafdd741fd0bf1af3f9e4cfd14" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl", hash = "sha256:0e34e276722ab7dd0dffa9e12fe2135a9b34a0e300c456ed7ad6430229404eb5" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:610f600c102386e581327d5efc18c0d6edecb9820b4140d26163354a99cd800d" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:cb9a8ba8137ab24e36bf1742cb79a1294bd374db570f09fc15a5e1318160db4e" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl", hash = "sha256:2be20b2c05a0cce10430cc25f32b689259640d273232b2de357c35729132256d" },
- { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl", hash = "sha256:99fc421a5d234580e45957a7b02effbf3e1c884a5dd077afc85352c77bf41434" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-linux_s390x.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-manylinux_2_28_x86_64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_amd64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp311-cp311-win_arm64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-linux_s390x.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_aarch64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_amd64.whl" },
+ { url = "https://download.pytorch.org/whl/cpu/torch-2.8.0%2Bcpu-cp312-cp312-win_arm64.whl" },
]
[[package]]
From 2ce7479967630a1fae9073804df3e9697fa06a4b Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Mon, 15 Sep 2025 22:42:53 -0600
Subject: [PATCH 34/77] chore(main): release 0.11.0 (#648)
---
CHANGELOG.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 40174fc4..e59f1ab6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [0.11.0](https://github.com/Monadical-SAS/reflector/compare/v0.10.0...v0.11.0) (2025-09-16)
+
+
+### Features
+
+* remove profanity filter that was there for conference ([#652](https://github.com/Monadical-SAS/reflector/issues/652)) ([b42f7cf](https://github.com/Monadical-SAS/reflector/commit/b42f7cfc606783afcee792590efcc78b507468ab))
+
+
+### Bug Fixes
+
+* zulip and consent handler on the file pipeline ([#645](https://github.com/Monadical-SAS/reflector/issues/645)) ([5f143fe](https://github.com/Monadical-SAS/reflector/commit/5f143fe3640875dcb56c26694254a93189281d17))
+* zulip stream and topic selection in share dialog ([#644](https://github.com/Monadical-SAS/reflector/issues/644)) ([c546e69](https://github.com/Monadical-SAS/reflector/commit/c546e69739e68bb74fbc877eb62609928e5b8de6))
+
## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11)
From fa049e8d068190ce7ea015fd9fcccb8543f54a3f Mon Sep 17 00:00:00 2001
From: Sergey Mankovsky
Date: Tue, 16 Sep 2025 10:57:35 +0200
Subject: [PATCH 35/77] fix: ignore player hotkeys for text inputs (#646)
* Ignore player hotkeys for text inputs
* Fix event listener effect
---
www/app/(app)/transcripts/player.tsx | 20 +++++++++++++++-----
1 file changed, 15 insertions(+), 5 deletions(-)
diff --git a/www/app/(app)/transcripts/player.tsx b/www/app/(app)/transcripts/player.tsx
index 10b02aa1..2cefe8c1 100644
--- a/www/app/(app)/transcripts/player.tsx
+++ b/www/app/(app)/transcripts/player.tsx
@@ -33,17 +33,27 @@ export default function Player(props: PlayerProps) {
const topicsRef = useRef(props.topics);
const [firstRender, setFirstRender] = useState(true);
- const keyHandler = (e) => {
- if (e.key == " ") {
+ const shouldIgnoreHotkeys = (target: EventTarget | null) => {
+ return (
+ target instanceof HTMLInputElement ||
+ target instanceof HTMLTextAreaElement
+ );
+ };
+
+ const keyHandler = (e: KeyboardEvent) => {
+ if (e.key === " ") {
+ if (e.repeat) return;
+ if (shouldIgnoreHotkeys(e.target)) return;
+ e.preventDefault();
wavesurfer?.playPause();
}
};
useEffect(() => {
- document.addEventListener("keyup", keyHandler);
+ document.addEventListener("keydown", keyHandler);
return () => {
- document.removeEventListener("keyup", keyHandler);
+ document.removeEventListener("keydown", keyHandler);
};
- });
+ }, [wavesurfer]);
// Waveform setup
useEffect(() => {
From ab859d65a6bded904133a163a081a651b3938d42 Mon Sep 17 00:00:00 2001
From: Sergey Mankovsky
Date: Wed, 17 Sep 2025 18:52:03 +0200
Subject: [PATCH 36/77] feat: self-hosted gpu api (#636)
* Self-hosted gpu api
* Refactor self-hosted api
* Rename model api tests
* Use lifespan instead of startup event
* Fix self hosted imports
* Add newlines
* Add response models
* Move gpu dir to the root
* Add project description
* Refactor lifespan
* Update env var names for model api tests
* Preload diarizarion service
* Refactor uploaded file paths
---
gpu/modal_deployments/.gitignore | 33 +
.../gpu => gpu}/modal_deployments/README.md | 0
.../modal_deployments/reflector_diarizer.py | 0
.../reflector_transcriber.py | 0
.../reflector_transcriber_parakeet.py | 0
.../modal_deployments/reflector_translator.py | 0
gpu/self_hosted/.env.example | 2 +
gpu/self_hosted/.gitignore | 38 +
gpu/self_hosted/Dockerfile | 46 +
gpu/self_hosted/README.md | 73 +
gpu/self_hosted/app/auth.py | 19 +
gpu/self_hosted/app/config.py | 12 +
gpu/self_hosted/app/factory.py | 30 +
gpu/self_hosted/app/routers/diarization.py | 30 +
gpu/self_hosted/app/routers/transcription.py | 109 +
gpu/self_hosted/app/routers/translation.py | 28 +
gpu/self_hosted/app/services/diarizer.py | 42 +
gpu/self_hosted/app/services/transcriber.py | 208 ++
gpu/self_hosted/app/services/translator.py | 44 +
gpu/self_hosted/app/utils.py | 107 +
gpu/self_hosted/compose.yml | 10 +
gpu/self_hosted/main.py | 3 +
gpu/self_hosted/pyproject.toml | 19 +
gpu/self_hosted/runserver.sh | 17 +
gpu/self_hosted/uv.lock | 3013 +++++++++++++++++
server/docs/gpu/api-transcription.md | 2 +-
server/pyproject.toml | 4 +-
server/tests/test_model_api_diarization.py | 63 +
...script.py => test_model_api_transcript.py} | 28 +-
server/tests/test_model_api_translation.py | 56 +
30 files changed, 4020 insertions(+), 16 deletions(-)
create mode 100644 gpu/modal_deployments/.gitignore
rename {server/gpu => gpu}/modal_deployments/README.md (100%)
rename {server/gpu => gpu}/modal_deployments/reflector_diarizer.py (100%)
rename {server/gpu => gpu}/modal_deployments/reflector_transcriber.py (100%)
rename {server/gpu => gpu}/modal_deployments/reflector_transcriber_parakeet.py (100%)
rename {server/gpu => gpu}/modal_deployments/reflector_translator.py (100%)
create mode 100644 gpu/self_hosted/.env.example
create mode 100644 gpu/self_hosted/.gitignore
create mode 100644 gpu/self_hosted/Dockerfile
create mode 100644 gpu/self_hosted/README.md
create mode 100644 gpu/self_hosted/app/auth.py
create mode 100644 gpu/self_hosted/app/config.py
create mode 100644 gpu/self_hosted/app/factory.py
create mode 100644 gpu/self_hosted/app/routers/diarization.py
create mode 100644 gpu/self_hosted/app/routers/transcription.py
create mode 100644 gpu/self_hosted/app/routers/translation.py
create mode 100644 gpu/self_hosted/app/services/diarizer.py
create mode 100644 gpu/self_hosted/app/services/transcriber.py
create mode 100644 gpu/self_hosted/app/services/translator.py
create mode 100644 gpu/self_hosted/app/utils.py
create mode 100644 gpu/self_hosted/compose.yml
create mode 100644 gpu/self_hosted/main.py
create mode 100644 gpu/self_hosted/pyproject.toml
create mode 100644 gpu/self_hosted/runserver.sh
create mode 100644 gpu/self_hosted/uv.lock
create mode 100644 server/tests/test_model_api_diarization.py
rename server/tests/{test_gpu_modal_transcript.py => test_model_api_transcript.py} (94%)
create mode 100644 server/tests/test_model_api_translation.py
diff --git a/gpu/modal_deployments/.gitignore b/gpu/modal_deployments/.gitignore
new file mode 100644
index 00000000..734bd3b2
--- /dev/null
+++ b/gpu/modal_deployments/.gitignore
@@ -0,0 +1,33 @@
+# OS / Editor
+.DS_Store
+.vscode/
+.idea/
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Logs
+*.log
+
+# Env and secrets
+.env
+.env.*
+*.env
+*.secret
+
+# Build / dist
+build/
+dist/
+.eggs/
+*.egg-info/
+
+# Coverage / test
+.pytest_cache/
+.coverage*
+htmlcov/
+
+# Modal local state (if any)
+modal_mounts/
+.modal_cache/
diff --git a/server/gpu/modal_deployments/README.md b/gpu/modal_deployments/README.md
similarity index 100%
rename from server/gpu/modal_deployments/README.md
rename to gpu/modal_deployments/README.md
diff --git a/server/gpu/modal_deployments/reflector_diarizer.py b/gpu/modal_deployments/reflector_diarizer.py
similarity index 100%
rename from server/gpu/modal_deployments/reflector_diarizer.py
rename to gpu/modal_deployments/reflector_diarizer.py
diff --git a/server/gpu/modal_deployments/reflector_transcriber.py b/gpu/modal_deployments/reflector_transcriber.py
similarity index 100%
rename from server/gpu/modal_deployments/reflector_transcriber.py
rename to gpu/modal_deployments/reflector_transcriber.py
diff --git a/server/gpu/modal_deployments/reflector_transcriber_parakeet.py b/gpu/modal_deployments/reflector_transcriber_parakeet.py
similarity index 100%
rename from server/gpu/modal_deployments/reflector_transcriber_parakeet.py
rename to gpu/modal_deployments/reflector_transcriber_parakeet.py
diff --git a/server/gpu/modal_deployments/reflector_translator.py b/gpu/modal_deployments/reflector_translator.py
similarity index 100%
rename from server/gpu/modal_deployments/reflector_translator.py
rename to gpu/modal_deployments/reflector_translator.py
diff --git a/gpu/self_hosted/.env.example b/gpu/self_hosted/.env.example
new file mode 100644
index 00000000..a55584ba
--- /dev/null
+++ b/gpu/self_hosted/.env.example
@@ -0,0 +1,2 @@
+REFLECTOR_GPU_APIKEY=
+HF_TOKEN=
diff --git a/gpu/self_hosted/.gitignore b/gpu/self_hosted/.gitignore
new file mode 100644
index 00000000..2773c2e2
--- /dev/null
+++ b/gpu/self_hosted/.gitignore
@@ -0,0 +1,38 @@
+cache/
+
+# OS / Editor
+.DS_Store
+.vscode/
+.idea/
+
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+
+# Env and secrets
+.env
+*.env
+*.secret
+HF_TOKEN
+REFLECTOR_GPU_APIKEY
+
+# Virtual env / uv
+.venv/
+venv/
+ENV/
+uv/
+
+# Build / dist
+build/
+dist/
+.eggs/
+*.egg-info/
+
+# Coverage / test
+.pytest_cache/
+.coverage*
+htmlcov/
+
+# Logs
+*.log
diff --git a/gpu/self_hosted/Dockerfile b/gpu/self_hosted/Dockerfile
new file mode 100644
index 00000000..4865fcc0
--- /dev/null
+++ b/gpu/self_hosted/Dockerfile
@@ -0,0 +1,46 @@
+FROM python:3.12-slim
+
+ENV PYTHONUNBUFFERED=1 \
+ UV_LINK_MODE=copy \
+ UV_NO_CACHE=1
+
+WORKDIR /tmp
+RUN apt-get update \
+ && apt-get install -y \
+ ffmpeg \
+ curl \
+ ca-certificates \
+ gnupg \
+ wget \
+ && apt-get clean
+# Add NVIDIA CUDA repo for Debian 12 (bookworm) and install cuDNN 9 for CUDA 12
+ADD https://developer.download.nvidia.com/compute/cuda/repos/debian12/x86_64/cuda-keyring_1.1-1_all.deb /cuda-keyring.deb
+RUN dpkg -i /cuda-keyring.deb \
+ && rm /cuda-keyring.deb \
+ && apt-get update \
+ && apt-get install -y --no-install-recommends \
+ cuda-cudart-12-6 \
+ libcublas-12-6 \
+ libcudnn9-cuda-12 \
+ libcudnn9-dev-cuda-12 \
+ && apt-get clean \
+ && rm -rf /var/lib/apt/lists/*
+ADD https://astral.sh/uv/install.sh /uv-installer.sh
+RUN sh /uv-installer.sh && rm /uv-installer.sh
+ENV PATH="/root/.local/bin/:$PATH"
+ENV LD_LIBRARY_PATH="/usr/local/cuda/lib64:/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
+
+RUN mkdir -p /app
+WORKDIR /app
+COPY pyproject.toml uv.lock /app/
+
+
+COPY ./app /app/app
+COPY ./main.py /app/
+COPY ./runserver.sh /app/
+
+EXPOSE 8000
+
+CMD ["sh", "/app/runserver.sh"]
+
+
diff --git a/gpu/self_hosted/README.md b/gpu/self_hosted/README.md
new file mode 100644
index 00000000..0180a8ae
--- /dev/null
+++ b/gpu/self_hosted/README.md
@@ -0,0 +1,73 @@
+# Self-hosted Model API
+
+Run transcription, translation, and diarization services compatible with Reflector's GPU Model API. Works on CPU or GPU.
+
+Environment variables
+
+- REFLECTOR_GPU_APIKEY: Optional Bearer token. If unset, auth is disabled.
+- HF_TOKEN: Optional. Required for diarization to download pyannote pipelines
+
+Requirements
+
+- FFmpeg must be installed and on PATH (used for URL-based and segmented transcription)
+- Python 3.12+
+- NVIDIA GPU optional. If available, it will be used automatically
+
+Local run
+Set env vars in self_hosted/.env file
+uv sync
+
+uv run uvicorn main:app --host 0.0.0.0 --port 8000
+
+Authentication
+
+- If REFLECTOR_GPU_APIKEY is set, include header: Authorization: Bearer
+
+Endpoints
+
+- POST /v1/audio/transcriptions
+
+ - multipart/form-data
+ - fields: file (single file) OR files[] (multiple files), language, batch (true/false)
+ - response: single { text, words, filename } or { results: [ ... ] }
+
+- POST /v1/audio/transcriptions-from-url
+
+ - application/json
+ - body: { audio_file_url, language, timestamp_offset }
+ - response: { text, words }
+
+- POST /translate
+
+ - text: query parameter
+ - body (application/json): { source_language, target_language }
+ - response: { text: { : original, : translated } }
+
+- POST /diarize
+ - query parameters: audio_file_url, timestamp (optional)
+ - requires HF_TOKEN to be set (for pyannote)
+ - response: { diarization: [ { start, end, speaker } ] }
+
+OpenAPI docs
+
+- Visit /docs when the server is running
+
+Docker
+
+- Not yet provided in this directory. A Dockerfile will be added later. For now, use Local run above
+
+Conformance tests
+
+# From this directory
+
+TRANSCRIPT_URL=http://localhost:8000 \
+TRANSCRIPT_API_KEY=dev-key \
+uv run -m pytest -m model_api --no-cov ../../server/tests/test_model_api_transcript.py
+
+TRANSLATION_URL=http://localhost:8000 \
+TRANSLATION_API_KEY=dev-key \
+uv run -m pytest -m model_api --no-cov ../../server/tests/test_model_api_translation.py
+
+DIARIZATION_URL=http://localhost:8000 \
+DIARIZATION_API_KEY=dev-key \
+uv run -m pytest -m model_api --no-cov ../../server/tests/test_model_api_diarization.py
diff --git a/gpu/self_hosted/app/auth.py b/gpu/self_hosted/app/auth.py
new file mode 100644
index 00000000..9c74e90c
--- /dev/null
+++ b/gpu/self_hosted/app/auth.py
@@ -0,0 +1,19 @@
+import os
+
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+
+
+def apikey_auth(apikey: str = Depends(oauth2_scheme)):
+ required_key = os.environ.get("REFLECTOR_GPU_APIKEY")
+ if not required_key:
+ return
+ if apikey == required_key:
+ return
+ raise HTTPException(
+ status_code=status.HTTP_401_UNAUTHORIZED,
+ detail="Invalid API key",
+ headers={"WWW-Authenticate": "Bearer"},
+ )
diff --git a/gpu/self_hosted/app/config.py b/gpu/self_hosted/app/config.py
new file mode 100644
index 00000000..5c466f00
--- /dev/null
+++ b/gpu/self_hosted/app/config.py
@@ -0,0 +1,12 @@
+from pathlib import Path
+
+SUPPORTED_FILE_EXTENSIONS = ["mp3", "mp4", "mpeg", "mpga", "m4a", "wav", "webm"]
+SAMPLE_RATE = 16000
+VAD_CONFIG = {
+ "batch_max_duration": 30.0,
+ "silence_padding": 0.5,
+ "window_size": 512,
+}
+
+# App-level paths
+UPLOADS_PATH = Path("/tmp/whisper-uploads")
diff --git a/gpu/self_hosted/app/factory.py b/gpu/self_hosted/app/factory.py
new file mode 100644
index 00000000..72dadcd7
--- /dev/null
+++ b/gpu/self_hosted/app/factory.py
@@ -0,0 +1,30 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+
+from .routers.diarization import router as diarization_router
+from .routers.transcription import router as transcription_router
+from .routers.translation import router as translation_router
+from .services.transcriber import WhisperService
+from .services.diarizer import PyannoteDiarizationService
+from .utils import ensure_dirs
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ ensure_dirs()
+ whisper_service = WhisperService()
+ whisper_service.load()
+ app.state.whisper = whisper_service
+ diarization_service = PyannoteDiarizationService()
+ diarization_service.load()
+ app.state.diarizer = diarization_service
+ yield
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(lifespan=lifespan)
+ app.include_router(transcription_router)
+ app.include_router(translation_router)
+ app.include_router(diarization_router)
+ return app
diff --git a/gpu/self_hosted/app/routers/diarization.py b/gpu/self_hosted/app/routers/diarization.py
new file mode 100644
index 00000000..113a8957
--- /dev/null
+++ b/gpu/self_hosted/app/routers/diarization.py
@@ -0,0 +1,30 @@
+from typing import List
+
+from fastapi import APIRouter, Depends, Request
+from pydantic import BaseModel
+
+from ..auth import apikey_auth
+from ..services.diarizer import PyannoteDiarizationService
+from ..utils import download_audio_file
+
+router = APIRouter(tags=["diarization"])
+
+
+class DiarizationSegment(BaseModel):
+ start: float
+ end: float
+ speaker: int
+
+
+class DiarizationResponse(BaseModel):
+ diarization: List[DiarizationSegment]
+
+
+@router.post(
+ "/diarize", dependencies=[Depends(apikey_auth)], response_model=DiarizationResponse
+)
+def diarize(request: Request, audio_file_url: str, timestamp: float = 0.0):
+ with download_audio_file(audio_file_url) as (file_path, _ext):
+ file_path = str(file_path)
+ diarizer: PyannoteDiarizationService = request.app.state.diarizer
+ return diarizer.diarize_file(file_path, timestamp=timestamp)
diff --git a/gpu/self_hosted/app/routers/transcription.py b/gpu/self_hosted/app/routers/transcription.py
new file mode 100644
index 00000000..04f1f7f7
--- /dev/null
+++ b/gpu/self_hosted/app/routers/transcription.py
@@ -0,0 +1,109 @@
+import uuid
+from typing import Optional, Union
+
+from fastapi import APIRouter, Body, Depends, Form, HTTPException, Request, UploadFile
+from pydantic import BaseModel
+from pathlib import Path
+from ..auth import apikey_auth
+from ..config import SUPPORTED_FILE_EXTENSIONS, UPLOADS_PATH
+from ..services.transcriber import MODEL_NAME
+from ..utils import cleanup_uploaded_files, download_audio_file
+
+router = APIRouter(prefix="/v1/audio", tags=["transcription"])
+
+
+class WordTiming(BaseModel):
+ word: str
+ start: float
+ end: float
+
+
+class TranscriptResult(BaseModel):
+ text: str
+ words: list[WordTiming]
+ filename: Optional[str] = None
+
+
+class TranscriptBatchResponse(BaseModel):
+ results: list[TranscriptResult]
+
+
+@router.post(
+ "/transcriptions",
+ dependencies=[Depends(apikey_auth)],
+ response_model=Union[TranscriptResult, TranscriptBatchResponse],
+)
+def transcribe(
+ request: Request,
+ file: UploadFile = None,
+ files: list[UploadFile] | None = None,
+ model: str = Form(MODEL_NAME),
+ language: str = Form("en"),
+ batch: bool = Form(False),
+):
+ service = request.app.state.whisper
+ if not file and not files:
+ raise HTTPException(
+ status_code=400, detail="Either 'file' or 'files' parameter is required"
+ )
+ if batch and not files:
+ raise HTTPException(
+ status_code=400, detail="Batch transcription requires 'files'"
+ )
+
+ upload_files = [file] if file else files
+
+ uploaded_paths: list[Path] = []
+ with cleanup_uploaded_files(uploaded_paths):
+ for upload_file in upload_files:
+ audio_suffix = upload_file.filename.split(".")[-1].lower()
+ if audio_suffix not in SUPPORTED_FILE_EXTENSIONS:
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Unsupported audio format. Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}"
+ ),
+ )
+ unique_filename = f"{uuid.uuid4()}.{audio_suffix}"
+ file_path = UPLOADS_PATH / unique_filename
+ with open(file_path, "wb") as f:
+ content = upload_file.file.read()
+ f.write(content)
+ uploaded_paths.append(file_path)
+
+ if batch and len(upload_files) > 1:
+ results = []
+ for path in uploaded_paths:
+ result = service.transcribe_file(str(path), language=language)
+ result["filename"] = path.name
+ results.append(result)
+ return {"results": results}
+
+ results = []
+ for path in uploaded_paths:
+ result = service.transcribe_file(str(path), language=language)
+ result["filename"] = path.name
+ results.append(result)
+
+ return {"results": results} if len(results) > 1 else results[0]
+
+
+@router.post(
+ "/transcriptions-from-url",
+ dependencies=[Depends(apikey_auth)],
+ response_model=TranscriptResult,
+)
+def transcribe_from_url(
+ request: Request,
+ audio_file_url: str = Body(..., description="URL of the audio file to transcribe"),
+ model: str = Body(MODEL_NAME),
+ language: str = Body("en"),
+ timestamp_offset: float = Body(0.0),
+):
+ service = request.app.state.whisper
+ with download_audio_file(audio_file_url) as (file_path, _ext):
+ file_path = str(file_path)
+ result = service.transcribe_vad_url_segment(
+ file_path=file_path, timestamp_offset=timestamp_offset, language=language
+ )
+ return result
diff --git a/gpu/self_hosted/app/routers/translation.py b/gpu/self_hosted/app/routers/translation.py
new file mode 100644
index 00000000..d2025416
--- /dev/null
+++ b/gpu/self_hosted/app/routers/translation.py
@@ -0,0 +1,28 @@
+from typing import Dict
+
+from fastapi import APIRouter, Body, Depends
+from pydantic import BaseModel
+
+from ..auth import apikey_auth
+from ..services.translator import TextTranslatorService
+
+router = APIRouter(tags=["translation"])
+
+translator = TextTranslatorService()
+
+
+class TranslationResponse(BaseModel):
+ text: Dict[str, str]
+
+
+@router.post(
+ "/translate",
+ dependencies=[Depends(apikey_auth)],
+ response_model=TranslationResponse,
+)
+def translate(
+ text: str,
+ source_language: str = Body("en"),
+ target_language: str = Body("fr"),
+):
+ return translator.translate(text, source_language, target_language)
diff --git a/gpu/self_hosted/app/services/diarizer.py b/gpu/self_hosted/app/services/diarizer.py
new file mode 100644
index 00000000..2935ffc5
--- /dev/null
+++ b/gpu/self_hosted/app/services/diarizer.py
@@ -0,0 +1,42 @@
+import os
+import threading
+
+import torch
+import torchaudio
+from pyannote.audio import Pipeline
+
+
+class PyannoteDiarizationService:
+ def __init__(self):
+ self._pipeline = None
+ self._device = "cpu"
+ self._lock = threading.Lock()
+
+ def load(self):
+ self._device = "cuda" if torch.cuda.is_available() else "cpu"
+ self._pipeline = Pipeline.from_pretrained(
+ "pyannote/speaker-diarization-3.1",
+ use_auth_token=os.environ.get("HF_TOKEN"),
+ )
+ self._pipeline.to(torch.device(self._device))
+
+ def diarize_file(self, file_path: str, timestamp: float = 0.0) -> dict:
+ if self._pipeline is None:
+ self.load()
+ waveform, sample_rate = torchaudio.load(file_path)
+ with self._lock:
+ diarization = self._pipeline(
+ {"waveform": waveform, "sample_rate": sample_rate}
+ )
+ words = []
+ for diarization_segment, _, speaker in diarization.itertracks(yield_label=True):
+ words.append(
+ {
+ "start": round(timestamp + diarization_segment.start, 3),
+ "end": round(timestamp + diarization_segment.end, 3),
+ "speaker": int(speaker[-2:])
+ if speaker and speaker[-2:].isdigit()
+ else 0,
+ }
+ )
+ return {"diarization": words}
diff --git a/gpu/self_hosted/app/services/transcriber.py b/gpu/self_hosted/app/services/transcriber.py
new file mode 100644
index 00000000..26a313cc
--- /dev/null
+++ b/gpu/self_hosted/app/services/transcriber.py
@@ -0,0 +1,208 @@
+import os
+import shutil
+import subprocess
+import threading
+from typing import Generator
+
+import faster_whisper
+import librosa
+import numpy as np
+import torch
+from fastapi import HTTPException
+from silero_vad import VADIterator, load_silero_vad
+
+from ..config import SAMPLE_RATE, VAD_CONFIG
+
+# Whisper configuration (service-local defaults)
+MODEL_NAME = "large-v2"
+# None delegates compute type to runtime: float16 on CUDA, int8 on CPU
+MODEL_COMPUTE_TYPE = None
+MODEL_NUM_WORKERS = 1
+CACHE_PATH = os.path.join(os.path.expanduser("~"), ".cache", "reflector-whisper")
+from ..utils import NoStdStreams
+
+
+class WhisperService:
+ def __init__(self):
+ self.model = None
+ self.device = "cpu"
+ self.lock = threading.Lock()
+
+ def load(self):
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
+ compute_type = MODEL_COMPUTE_TYPE or (
+ "float16" if self.device == "cuda" else "int8"
+ )
+ self.model = faster_whisper.WhisperModel(
+ MODEL_NAME,
+ device=self.device,
+ compute_type=compute_type,
+ num_workers=MODEL_NUM_WORKERS,
+ download_root=CACHE_PATH,
+ )
+
+ def pad_audio(self, audio_array, sample_rate: int = SAMPLE_RATE):
+ audio_duration = len(audio_array) / sample_rate
+ if audio_duration < VAD_CONFIG["silence_padding"]:
+ silence_samples = int(sample_rate * VAD_CONFIG["silence_padding"])
+ silence = np.zeros(silence_samples, dtype=np.float32)
+ return np.concatenate([audio_array, silence])
+ return audio_array
+
+ def enforce_word_timing_constraints(self, words: list[dict]) -> list[dict]:
+ if len(words) <= 1:
+ return words
+ enforced: list[dict] = []
+ for i, word in enumerate(words):
+ current = dict(word)
+ if i < len(words) - 1:
+ next_start = words[i + 1]["start"]
+ if current["end"] > next_start:
+ current["end"] = next_start
+ enforced.append(current)
+ return enforced
+
+ def transcribe_file(self, file_path: str, language: str = "en") -> dict:
+ input_for_model: str | "object" = file_path
+ try:
+ audio_array, _sample_rate = librosa.load(
+ file_path, sr=SAMPLE_RATE, mono=True
+ )
+ if len(audio_array) / float(SAMPLE_RATE) < VAD_CONFIG["silence_padding"]:
+ input_for_model = self.pad_audio(audio_array, SAMPLE_RATE)
+ except Exception:
+ pass
+
+ with self.lock:
+ with NoStdStreams():
+ segments, _ = self.model.transcribe(
+ input_for_model,
+ language=language,
+ beam_size=5,
+ word_timestamps=True,
+ vad_filter=True,
+ vad_parameters={"min_silence_duration_ms": 500},
+ )
+
+ segments = list(segments)
+ text = "".join(segment.text for segment in segments).strip()
+ words = [
+ {
+ "word": word.word,
+ "start": round(float(word.start), 2),
+ "end": round(float(word.end), 2),
+ }
+ for segment in segments
+ for word in segment.words
+ ]
+ words = self.enforce_word_timing_constraints(words)
+ return {"text": text, "words": words}
+
+ def transcribe_vad_url_segment(
+ self, file_path: str, timestamp_offset: float = 0.0, language: str = "en"
+ ) -> dict:
+ def load_audio_via_ffmpeg(input_path: str, sample_rate: int) -> np.ndarray:
+ ffmpeg_bin = shutil.which("ffmpeg") or "ffmpeg"
+ cmd = [
+ ffmpeg_bin,
+ "-nostdin",
+ "-threads",
+ "1",
+ "-i",
+ input_path,
+ "-f",
+ "f32le",
+ "-acodec",
+ "pcm_f32le",
+ "-ac",
+ "1",
+ "-ar",
+ str(sample_rate),
+ "pipe:1",
+ ]
+ try:
+ proc = subprocess.run(
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
+ )
+ except Exception as e:
+ raise HTTPException(status_code=400, detail=f"ffmpeg failed: {e}")
+ audio = np.frombuffer(proc.stdout, dtype=np.float32)
+ return audio
+
+ def vad_segments(
+ audio_array,
+ sample_rate: int = SAMPLE_RATE,
+ window_size: int = VAD_CONFIG["window_size"],
+ ) -> Generator[tuple[float, float], None, None]:
+ vad_model = load_silero_vad(onnx=False)
+ iterator = VADIterator(vad_model, sampling_rate=sample_rate)
+ start = None
+ for i in range(0, len(audio_array), window_size):
+ chunk = audio_array[i : i + window_size]
+ if len(chunk) < window_size:
+ chunk = np.pad(
+ chunk, (0, window_size - len(chunk)), mode="constant"
+ )
+ speech = iterator(chunk)
+ if not speech:
+ continue
+ if "start" in speech:
+ start = speech["start"]
+ continue
+ if "end" in speech and start is not None:
+ end = speech["end"]
+ yield (start / float(SAMPLE_RATE), end / float(SAMPLE_RATE))
+ start = None
+ iterator.reset_states()
+
+ audio_array = load_audio_via_ffmpeg(file_path, SAMPLE_RATE)
+
+ merged_batches: list[tuple[float, float]] = []
+ batch_start = None
+ batch_end = None
+ max_duration = VAD_CONFIG["batch_max_duration"]
+ for seg_start, seg_end in vad_segments(audio_array):
+ if batch_start is None:
+ batch_start, batch_end = seg_start, seg_end
+ continue
+ if seg_end - batch_start <= max_duration:
+ batch_end = seg_end
+ else:
+ merged_batches.append((batch_start, batch_end))
+ batch_start, batch_end = seg_start, seg_end
+ if batch_start is not None and batch_end is not None:
+ merged_batches.append((batch_start, batch_end))
+
+ all_text = []
+ all_words = []
+ for start_time, end_time in merged_batches:
+ s_idx = int(start_time * SAMPLE_RATE)
+ e_idx = int(end_time * SAMPLE_RATE)
+ segment = audio_array[s_idx:e_idx]
+ segment = self.pad_audio(segment, SAMPLE_RATE)
+ with self.lock:
+ segments, _ = self.model.transcribe(
+ segment,
+ language=language,
+ beam_size=5,
+ word_timestamps=True,
+ vad_filter=True,
+ vad_parameters={"min_silence_duration_ms": 500},
+ )
+ segments = list(segments)
+ text = "".join(seg.text for seg in segments).strip()
+ words = [
+ {
+ "word": w.word,
+ "start": round(float(w.start) + start_time + timestamp_offset, 2),
+ "end": round(float(w.end) + start_time + timestamp_offset, 2),
+ }
+ for seg in segments
+ for w in seg.words
+ ]
+ if text:
+ all_text.append(text)
+ all_words.extend(words)
+
+ all_words = self.enforce_word_timing_constraints(all_words)
+ return {"text": " ".join(all_text), "words": all_words}
diff --git a/gpu/self_hosted/app/services/translator.py b/gpu/self_hosted/app/services/translator.py
new file mode 100644
index 00000000..bda7373f
--- /dev/null
+++ b/gpu/self_hosted/app/services/translator.py
@@ -0,0 +1,44 @@
+import threading
+
+from transformers import MarianMTModel, MarianTokenizer, pipeline
+
+
+class TextTranslatorService:
+ """Simple text-to-text translator using HuggingFace MarianMT models.
+
+ This mirrors the modal translator API shape but uses text translation only.
+ """
+
+ def __init__(self):
+ self._pipeline = None
+ self._lock = threading.Lock()
+
+ def load(self, source_language: str = "en", target_language: str = "fr"):
+ # Pick a default MarianMT model pair if available; fall back to Helsinki-NLP en->fr
+ model_name = self._resolve_model_name(source_language, target_language)
+ tokenizer = MarianTokenizer.from_pretrained(model_name)
+ model = MarianMTModel.from_pretrained(model_name)
+ self._pipeline = pipeline("translation", model=model, tokenizer=tokenizer)
+
+ def _resolve_model_name(self, src: str, tgt: str) -> str:
+ # Minimal mapping; extend as needed
+ pair = (src.lower(), tgt.lower())
+ mapping = {
+ ("en", "fr"): "Helsinki-NLP/opus-mt-en-fr",
+ ("fr", "en"): "Helsinki-NLP/opus-mt-fr-en",
+ ("en", "es"): "Helsinki-NLP/opus-mt-en-es",
+ ("es", "en"): "Helsinki-NLP/opus-mt-es-en",
+ ("en", "de"): "Helsinki-NLP/opus-mt-en-de",
+ ("de", "en"): "Helsinki-NLP/opus-mt-de-en",
+ }
+ return mapping.get(pair, "Helsinki-NLP/opus-mt-en-fr")
+
+ def translate(self, text: str, source_language: str, target_language: str) -> dict:
+ if self._pipeline is None:
+ self.load(source_language, target_language)
+ with self._lock:
+ results = self._pipeline(
+ text, src_lang=source_language, tgt_lang=target_language
+ )
+ translated = results[0]["translation_text"] if results else ""
+ return {"text": {source_language: text, target_language: translated}}
diff --git a/gpu/self_hosted/app/utils.py b/gpu/self_hosted/app/utils.py
new file mode 100644
index 00000000..679804cb
--- /dev/null
+++ b/gpu/self_hosted/app/utils.py
@@ -0,0 +1,107 @@
+import logging
+import os
+import sys
+import uuid
+from contextlib import contextmanager
+from typing import Mapping
+from urllib.parse import urlparse
+from pathlib import Path
+
+import requests
+from fastapi import HTTPException
+
+from .config import SUPPORTED_FILE_EXTENSIONS, UPLOADS_PATH
+
+logger = logging.getLogger(__name__)
+
+
+class NoStdStreams:
+ def __init__(self):
+ self.devnull = open(os.devnull, "w")
+
+ def __enter__(self):
+ self._stdout, self._stderr = sys.stdout, sys.stderr
+ self._stdout.flush()
+ self._stderr.flush()
+ sys.stdout, sys.stderr = self.devnull, self.devnull
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ sys.stdout, sys.stderr = self._stdout, self._stderr
+ self.devnull.close()
+
+
+def ensure_dirs():
+ UPLOADS_PATH.mkdir(parents=True, exist_ok=True)
+
+
+def detect_audio_format(url: str, headers: Mapping[str, str]) -> str:
+ url_path = urlparse(url).path
+ for ext in SUPPORTED_FILE_EXTENSIONS:
+ if url_path.lower().endswith(f".{ext}"):
+ return ext
+
+ content_type = headers.get("content-type", "").lower()
+ if "audio/mpeg" in content_type or "audio/mp3" in content_type:
+ return "mp3"
+ if "audio/wav" in content_type:
+ return "wav"
+ if "audio/mp4" in content_type:
+ return "mp4"
+
+ raise HTTPException(
+ status_code=400,
+ detail=(
+ f"Unsupported audio format for URL. Supported extensions: {', '.join(SUPPORTED_FILE_EXTENSIONS)}"
+ ),
+ )
+
+
+def download_audio_to_uploads(audio_file_url: str) -> tuple[Path, str]:
+ response = requests.head(audio_file_url, allow_redirects=True)
+ if response.status_code == 404:
+ raise HTTPException(status_code=404, detail="Audio file not found")
+
+ response = requests.get(audio_file_url, allow_redirects=True)
+ response.raise_for_status()
+
+ audio_suffix = detect_audio_format(audio_file_url, response.headers)
+ unique_filename = f"{uuid.uuid4()}.{audio_suffix}"
+ file_path: Path = UPLOADS_PATH / unique_filename
+
+ with open(file_path, "wb") as f:
+ f.write(response.content)
+
+ return file_path, audio_suffix
+
+
+@contextmanager
+def download_audio_file(audio_file_url: str):
+ """Download an audio file to UPLOADS_PATH and remove it after use.
+
+ Yields (file_path: Path, audio_suffix: str).
+ """
+ file_path, audio_suffix = download_audio_to_uploads(audio_file_url)
+ try:
+ yield file_path, audio_suffix
+ finally:
+ try:
+ file_path.unlink(missing_ok=True)
+ except Exception as e:
+ logger.error("Error deleting temporary file %s: %s", file_path, e)
+
+
+@contextmanager
+def cleanup_uploaded_files(file_paths: list[Path]):
+ """Ensure provided file paths are removed after use.
+
+ The provided list can be populated inside the context; all present entries
+ at exit will be deleted.
+ """
+ try:
+ yield file_paths
+ finally:
+ for path in list(file_paths):
+ try:
+ path.unlink(missing_ok=True)
+ except Exception as e:
+ logger.error("Error deleting temporary file %s: %s", path, e)
diff --git a/gpu/self_hosted/compose.yml b/gpu/self_hosted/compose.yml
new file mode 100644
index 00000000..4f04935a
--- /dev/null
+++ b/gpu/self_hosted/compose.yml
@@ -0,0 +1,10 @@
+services:
+ reflector_gpu:
+ build:
+ context: .
+ ports:
+ - "8000:8000"
+ env_file:
+ - .env
+ volumes:
+ - ./cache:/root/.cache
diff --git a/gpu/self_hosted/main.py b/gpu/self_hosted/main.py
new file mode 100644
index 00000000..52617d24
--- /dev/null
+++ b/gpu/self_hosted/main.py
@@ -0,0 +1,3 @@
+from app.factory import create_app
+
+app = create_app()
diff --git a/gpu/self_hosted/pyproject.toml b/gpu/self_hosted/pyproject.toml
new file mode 100644
index 00000000..7cd3007d
--- /dev/null
+++ b/gpu/self_hosted/pyproject.toml
@@ -0,0 +1,19 @@
+[project]
+name = "reflector-gpu"
+version = "0.1.0"
+description = "Self-hosted GPU service for speech transcription, diarization, and translation via FastAPI."
+readme = "README.md"
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi[standard]>=0.116.1",
+ "uvicorn[standard]>=0.30.0",
+ "torch>=2.3.0",
+ "faster-whisper>=1.1.0",
+ "librosa==0.10.1",
+ "numpy<2",
+ "silero-vad==5.1.0",
+ "transformers>=4.35.0",
+ "sentencepiece",
+ "pyannote.audio==3.1.0",
+ "torchaudio>=2.3.0",
+]
diff --git a/gpu/self_hosted/runserver.sh b/gpu/self_hosted/runserver.sh
new file mode 100644
index 00000000..851dd535
--- /dev/null
+++ b/gpu/self_hosted/runserver.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+set -e
+
+export PATH="/root/.local/bin:$PATH"
+cd /app
+
+# Install Python dependencies at runtime (first run or when FORCE_SYNC=1)
+if [ ! -d "/app/.venv" ] || [ "$FORCE_SYNC" = "1" ]; then
+ echo "[startup] Installing Python dependencies with uv..."
+ uv sync --compile-bytecode --locked
+else
+ echo "[startup] Using existing virtual environment at /app/.venv"
+fi
+
+exec uv run uvicorn main:app --host 0.0.0.0 --port 8000
+
+
diff --git a/gpu/self_hosted/uv.lock b/gpu/self_hosted/uv.lock
new file mode 100644
index 00000000..224e9d33
--- /dev/null
+++ b/gpu/self_hosted/uv.lock
@@ -0,0 +1,3013 @@
+version = 1
+revision = 2
+requires-python = ">=3.12"
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.12.15"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/d92a237d8802ca88483906c388f7c201bbe96cd80a165ffd0ac2f6a8d59f/aiohttp-3.12.15.tar.gz", hash = "sha256:4fc61385e9c98d72fcdf47e6dd81833f47b2f77c114c29cd64a361be57a763a2", size = 7823716, upload-time = "2025-07-29T05:52:32.215Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/63/97/77cb2450d9b35f517d6cf506256bf4f5bda3f93a66b4ad64ba7fc917899c/aiohttp-3.12.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:802d3868f5776e28f7bf69d349c26fc0efadb81676d0afa88ed00d98a26340b7", size = 702333, upload-time = "2025-07-29T05:50:46.507Z" },
+ { url = "https://files.pythonhosted.org/packages/83/6d/0544e6b08b748682c30b9f65640d006e51f90763b41d7c546693bc22900d/aiohttp-3.12.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2800614cd560287be05e33a679638e586a2d7401f4ddf99e304d98878c29444", size = 476948, upload-time = "2025-07-29T05:50:48.067Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/1d/c8c40e611e5094330284b1aea8a4b02ca0858f8458614fa35754cab42b9c/aiohttp-3.12.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8466151554b593909d30a0a125d638b4e5f3836e5aecde85b66b80ded1cb5b0d", size = 469787, upload-time = "2025-07-29T05:50:49.669Z" },
+ { url = "https://files.pythonhosted.org/packages/38/7d/b76438e70319796bfff717f325d97ce2e9310f752a267bfdf5192ac6082b/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e5a495cb1be69dae4b08f35a6c4579c539e9b5706f606632102c0f855bcba7c", size = 1716590, upload-time = "2025-07-29T05:50:51.368Z" },
+ { url = "https://files.pythonhosted.org/packages/79/b1/60370d70cdf8b269ee1444b390cbd72ce514f0d1cd1a715821c784d272c9/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6404dfc8cdde35c69aaa489bb3542fb86ef215fc70277c892be8af540e5e21c0", size = 1699241, upload-time = "2025-07-29T05:50:53.628Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/2b/4968a7b8792437ebc12186db31523f541943e99bda8f30335c482bea6879/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ead1c00f8521a5c9070fcb88f02967b1d8a0544e6d85c253f6968b785e1a2ab", size = 1754335, upload-time = "2025-07-29T05:50:55.394Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/c1/49524ed553f9a0bec1a11fac09e790f49ff669bcd14164f9fab608831c4d/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6990ef617f14450bc6b34941dba4f12d5613cbf4e33805932f853fbd1cf18bfb", size = 1800491, upload-time = "2025-07-29T05:50:57.202Z" },
+ { url = "https://files.pythonhosted.org/packages/de/5e/3bf5acea47a96a28c121b167f5ef659cf71208b19e52a88cdfa5c37f1fcc/aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", size = 1719929, upload-time = "2025-07-29T05:50:59.192Z" },
+ { url = "https://files.pythonhosted.org/packages/39/94/8ae30b806835bcd1cba799ba35347dee6961a11bd507db634516210e91d8/aiohttp-3.12.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c5092ce14361a73086b90c6efb3948ffa5be2f5b6fbcf52e8d8c8b8848bb97c", size = 1635733, upload-time = "2025-07-29T05:51:01.394Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/46/06cdef71dd03acd9da7f51ab3a9107318aee12ad38d273f654e4f981583a/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aaa2234bb60c4dbf82893e934d8ee8dea30446f0647e024074237a56a08c01bd", size = 1696790, upload-time = "2025-07-29T05:51:03.657Z" },
+ { url = "https://files.pythonhosted.org/packages/02/90/6b4cfaaf92ed98d0ec4d173e78b99b4b1a7551250be8937d9d67ecb356b4/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:6d86a2fbdd14192e2f234a92d3b494dd4457e683ba07e5905a0b3ee25389ac9f", size = 1718245, upload-time = "2025-07-29T05:51:05.911Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e6/2593751670fa06f080a846f37f112cbe6f873ba510d070136a6ed46117c6/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a041e7e2612041a6ddf1c6a33b883be6a421247c7afd47e885969ee4cc58bd8d", size = 1658899, upload-time = "2025-07-29T05:51:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/28/c15bacbdb8b8eb5bf39b10680d129ea7410b859e379b03190f02fa104ffd/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5015082477abeafad7203757ae44299a610e89ee82a1503e3d4184e6bafdd519", size = 1738459, upload-time = "2025-07-29T05:51:09.56Z" },
+ { url = "https://files.pythonhosted.org/packages/00/de/c269cbc4faa01fb10f143b1670633a8ddd5b2e1ffd0548f7aa49cb5c70e2/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:56822ff5ddfd1b745534e658faba944012346184fbfe732e0d6134b744516eea", size = 1766434, upload-time = "2025-07-29T05:51:11.423Z" },
+ { url = "https://files.pythonhosted.org/packages/52/b0/4ff3abd81aa7d929b27d2e1403722a65fc87b763e3a97b3a2a494bfc63bc/aiohttp-3.12.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b2acbbfff69019d9014508c4ba0401822e8bae5a5fdc3b6814285b71231b60f3", size = 1726045, upload-time = "2025-07-29T05:51:13.689Z" },
+ { url = "https://files.pythonhosted.org/packages/71/16/949225a6a2dd6efcbd855fbd90cf476052e648fb011aa538e3b15b89a57a/aiohttp-3.12.15-cp312-cp312-win32.whl", hash = "sha256:d849b0901b50f2185874b9a232f38e26b9b3d4810095a7572eacea939132d4e1", size = 423591, upload-time = "2025-07-29T05:51:15.452Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/d8/fa65d2a349fe938b76d309db1a56a75c4fb8cc7b17a398b698488a939903/aiohttp-3.12.15-cp312-cp312-win_amd64.whl", hash = "sha256:b390ef5f62bb508a9d67cb3bba9b8356e23b3996da7062f1a57ce1a79d2b3d34", size = 450266, upload-time = "2025-07-29T05:51:17.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/33/918091abcf102e39d15aba2476ad9e7bd35ddb190dcdd43a854000d3da0d/aiohttp-3.12.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9f922ffd05034d439dde1c77a20461cf4a1b0831e6caa26151fe7aa8aaebc315", size = 696741, upload-time = "2025-07-29T05:51:19.021Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/2a/7495a81e39a998e400f3ecdd44a62107254803d1681d9189be5c2e4530cd/aiohttp-3.12.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2ee8a8ac39ce45f3e55663891d4b1d15598c157b4d494a4613e704c8b43112cd", size = 474407, upload-time = "2025-07-29T05:51:21.165Z" },
+ { url = "https://files.pythonhosted.org/packages/49/fc/a9576ab4be2dcbd0f73ee8675d16c707cfc12d5ee80ccf4015ba543480c9/aiohttp-3.12.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eae49032c29d356b94eee45a3f39fdf4b0814b397638c2f718e96cfadf4c4e4", size = 466703, upload-time = "2025-07-29T05:51:22.948Z" },
+ { url = "https://files.pythonhosted.org/packages/09/2f/d4bcc8448cf536b2b54eed48f19682031ad182faa3a3fee54ebe5b156387/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b97752ff12cc12f46a9b20327104448042fce5c33a624f88c18f66f9368091c7", size = 1705532, upload-time = "2025-07-29T05:51:25.211Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/f3/59406396083f8b489261e3c011aa8aee9df360a96ac8fa5c2e7e1b8f0466/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:894261472691d6fe76ebb7fcf2e5870a2ac284c7406ddc95823c8598a1390f0d", size = 1686794, upload-time = "2025-07-29T05:51:27.145Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/71/164d194993a8d114ee5656c3b7ae9c12ceee7040d076bf7b32fb98a8c5c6/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5fa5d9eb82ce98959fc1031c28198b431b4d9396894f385cb63f1e2f3f20ca6b", size = 1738865, upload-time = "2025-07-29T05:51:29.366Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/00/d198461b699188a93ead39cb458554d9f0f69879b95078dce416d3209b54/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0fa751efb11a541f57db59c1dd821bec09031e01452b2b6217319b3a1f34f3d", size = 1788238, upload-time = "2025-07-29T05:51:31.285Z" },
+ { url = "https://files.pythonhosted.org/packages/85/b8/9e7175e1fa0ac8e56baa83bf3c214823ce250d0028955dfb23f43d5e61fd/aiohttp-3.12.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5346b93e62ab51ee2a9d68e8f73c7cf96ffb73568a23e683f931e52450e4148d", size = 1710566, upload-time = "2025-07-29T05:51:33.219Z" },
+ { url = "https://files.pythonhosted.org/packages/59/e4/16a8eac9df39b48ae102ec030fa9f726d3570732e46ba0c592aeeb507b93/aiohttp-3.12.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:049ec0360f939cd164ecbfd2873eaa432613d5e77d6b04535e3d1fbae5a9e645", size = 1624270, upload-time = "2025-07-29T05:51:35.195Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/f8/cd84dee7b6ace0740908fd0af170f9fab50c2a41ccbc3806aabcb1050141/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b52dcf013b57464b6d1e51b627adfd69a8053e84b7103a7cd49c030f9ca44461", size = 1677294, upload-time = "2025-07-29T05:51:37.215Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/42/d0f1f85e50d401eccd12bf85c46ba84f947a84839c8a1c2c5f6e8ab1eb50/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b2af240143dd2765e0fb661fd0361a1b469cab235039ea57663cda087250ea9", size = 1708958, upload-time = "2025-07-29T05:51:39.328Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6b/f6fa6c5790fb602538483aa5a1b86fcbad66244997e5230d88f9412ef24c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac77f709a2cde2cc71257ab2d8c74dd157c67a0558a0d2799d5d571b4c63d44d", size = 1651553, upload-time = "2025-07-29T05:51:41.356Z" },
+ { url = "https://files.pythonhosted.org/packages/04/36/a6d36ad545fa12e61d11d1932eef273928b0495e6a576eb2af04297fdd3c/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:47f6b962246f0a774fbd3b6b7be25d59b06fdb2f164cf2513097998fc6a29693", size = 1727688, upload-time = "2025-07-29T05:51:43.452Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c8/f195e5e06608a97a4e52c5d41c7927301bf757a8e8bb5bbf8cef6c314961/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:760fb7db442f284996e39cf9915a94492e1896baac44f06ae551974907922b64", size = 1761157, upload-time = "2025-07-29T05:51:45.643Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6a/ea199e61b67f25ba688d3ce93f63b49b0a4e3b3d380f03971b4646412fc6/aiohttp-3.12.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad702e57dc385cae679c39d318def49aef754455f237499d5b99bea4ef582e51", size = 1710050, upload-time = "2025-07-29T05:51:48.203Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/2e/ffeb7f6256b33635c29dbed29a22a723ff2dd7401fff42ea60cf2060abfb/aiohttp-3.12.15-cp313-cp313-win32.whl", hash = "sha256:f813c3e9032331024de2eb2e32a88d86afb69291fbc37a3a3ae81cc9917fb3d0", size = 422647, upload-time = "2025-07-29T05:51:50.718Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8e/78ee35774201f38d5e1ba079c9958f7629b1fd079459aea9467441dbfbf5/aiohttp-3.12.15-cp313-cp313-win_amd64.whl", hash = "sha256:1a649001580bdb37c6fdb1bebbd7e3bc688e8ec2b5c6f52edbb664662b17dc84", size = 449067, upload-time = "2025-07-29T05:51:52.549Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "alembic"
+version = "1.16.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mako" },
+ { name = "sqlalchemy" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "antlr4-python3-runtime"
+version = "4.9.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" }
+
+[[package]]
+name = "anyio"
+version = "4.10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "sniffio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
+]
+
+[[package]]
+name = "asteroid-filterbanks"
+version = "0.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "torch" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/fa/5c2be1f96dc179f83cdd3bb267edbd1f47d08f756785c016d5c2163901a7/asteroid-filterbanks-0.4.0.tar.gz", hash = "sha256:415f89d1dcf2b13b35f03f7a9370968ac4e6fa6800633c522dac992b283409b9", size = 24599, upload-time = "2021-04-09T20:03:07.456Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/7c/83ff6046176a675e6a1e8aeefed8892cd97fe7c46af93cc540d1b24b8323/asteroid_filterbanks-0.4.0-py3-none-any.whl", hash = "sha256:4932ac8b6acc6e08fb87cbe8ece84215b5a74eee284fe83acf3540a72a02eaf5", size = 29912, upload-time = "2021-04-09T20:03:05.817Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "25.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
+]
+
+[[package]]
+name = "audioread"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/db/d2/87016ca9f083acadffb2d8da59bfa3253e4da7eeb9f71fb8e7708dc97ecd/audioread-3.0.1.tar.gz", hash = "sha256:ac5460a5498c48bdf2e8e767402583a4dcd13f4414d286f42ce4379e8b35066d", size = 116513, upload-time = "2023-09-27T19:27:53.084Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/8d/30aa32745af16af0a9a650115fbe81bde7c610ed5c21b381fca0196f3a7f/audioread-3.0.1-py3-none-any.whl", hash = "sha256:4cdce70b8adc0da0a3c9e0d85fb10b3ace30fbdf8d1670fd443929b61d117c33", size = 23492, upload-time = "2023-09-27T19:27:51.334Z" },
+]
+
+[[package]]
+name = "av"
+version = "15.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e9/c3/83e6e73d1592bc54436eae0bc61704ae0cff0c3cfbde7b58af9ed67ebb49/av-15.1.0.tar.gz", hash = "sha256:39cda2dc810e11c1938f8cb5759c41d6b630550236b3365790e67a313660ec85", size = 3774192, upload-time = "2025-08-30T04:41:56.076Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/58/de78b276d20db6ffcd4371283df771721a833ba525a3d57e753d00a9fe79/av-15.1.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:40c5df37f4c354ab8190c6fd68dab7881d112f527906f64ca73da4c252a58cee", size = 21760991, upload-time = "2025-08-30T04:40:00.801Z" },
+ { url = "https://files.pythonhosted.org/packages/56/cc/45f85775304ae60b66976360d82ba5b152ad3fd91f9267d5020a51e9a828/av-15.1.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:af455ce65ada3d361f80c90c810d9bced4db5655ab9aa513024d6c71c5c476d5", size = 26953097, upload-time = "2025-08-30T04:40:03.998Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/f8/2d781e5e71d02fc829487e775ccb1185e72f95340d05f2e84eb57a11e093/av-15.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86226d2474c80c3393fa07a9c366106029ae500716098b72b3ec3f67205524c3", size = 38319710, upload-time = "2025-08-30T04:40:07.701Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/13/37737ef2193e83862ccacff23580c39de251da456a1bf0459e762cca273c/av-15.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:11326f197e7001c4ca53a83b2dbc67fd39ddff8cdf62ce6be3b22d9f3f9338bd", size = 39915519, upload-time = "2025-08-30T04:40:11.066Z" },
+ { url = "https://files.pythonhosted.org/packages/26/e9/e8032c7b8f2a4129a03f63f896544f8b7cf068e2db2950326fa2400d5c47/av-15.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a631ea879cc553080ee62874f4284765c42ba08ee0279851a98a85e2ceb3cc8d", size = 40286166, upload-time = "2025-08-30T04:40:14.561Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/23/612c0fd809444d04b8387a2dfd942ccc77829507bd78a387ff65a9d98c24/av-15.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f383949b010c3e731c245f80351d19dc0c08f345e194fc46becb1cb279be3ff", size = 41150592, upload-time = "2025-08-30T04:40:17.951Z" },
+ { url = "https://files.pythonhosted.org/packages/15/74/6f8e38a3b0aea5f28e72813672ff45b64615f2c69e6a4a558718c95edb9f/av-15.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d5921aa45f4c1f8c1a8d8185eb347e02aa4c3071278a2e2dd56368d54433d643", size = 31336093, upload-time = "2025-08-30T04:40:21.393Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/bc/78b2ffa8235eeffc29aa4a8cc47b02e660cfec32f601f39a00975fb06d0e/av-15.1.0-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2f77853c3119c59d1bff4214ccbe46e3133eccff85ed96adee51c68684443f4e", size = 21726244, upload-time = "2025-08-30T04:40:24.14Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/99/66d69453a2dce028e6e8ebea085d90e880aac03d3a3ab7d8ec16755ffd75/av-15.1.0-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:c0bc4471c156a0a1c70a607502434f477bc8dfe085eef905e55b4b0d66bcd3a5", size = 26918663, upload-time = "2025-08-30T04:40:27.557Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/51/1a7dfbeda71f2772bc46d758af0e7fab1cc8388ce4bc7f24aecbc4bfd764/av-15.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:37839d4fa1407f047af82560dfc0f94d8d6266071eff49e1cbe16c4483054621", size = 38041408, upload-time = "2025-08-30T04:40:30.811Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/97/2c4e0288ad4359b6064cb06ae79c2ff3a84ac73d27e91f2161b75fcd86fa/av-15.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:729179cd8622815e8b6f6854d13a806fe710576e08895c77e5e4ad254609de9a", size = 39642563, upload-time = "2025-08-30T04:40:34.617Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/94/2362502149e276d00957edabcc201a5f4d5109a8a7b4fd30793714a532f3/av-15.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4abdf085bfa4eec318efccff567831b361ea56c045cc38366811552e3127c665", size = 40022119, upload-time = "2025-08-30T04:40:37.703Z" },
+ { url = "https://files.pythonhosted.org/packages/df/58/1a0ce1b3835d9728da0a7a54aeffaa0a2b1a88405eaed9322efd55212a54/av-15.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f985661644879e4520d28a995fcb2afeb951bc15a1d51412eb8e5f36da85b6fe", size = 40885158, upload-time = "2025-08-30T04:40:40.952Z" },
+ { url = "https://files.pythonhosted.org/packages/30/e6/054bb64e424d90b77ed5fc6a7358e4013fb436154c998fc90a89a186313f/av-15.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:7d7804a44c8048bb4b014a99353dd124663a12cd1d4613ba2bd3b457c3b1d539", size = 31312256, upload-time = "2025-08-30T04:40:44.224Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/8b/89eae6dca10d7d2b83c131025a31ccc750be78699ac0304439faa1d1df99/av-15.1.0-cp314-cp314-macosx_13_0_arm64.whl", hash = "sha256:5dd73c6447947edcb82e5fecf96e1f146aeda0f169c7ad4c54df4d9f66f63fde", size = 21730645, upload-time = "2025-08-30T04:40:47.259Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/f0/abffaf69405ed68041524be12a1e294faf396971d6a0e70eb00e93687df7/av-15.1.0-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:a81cd515934a5d51290aa66b059b7ed29c4a212e704f3c5e99e32877ff1c312c", size = 26913753, upload-time = "2025-08-30T04:40:50.445Z" },
+ { url = "https://files.pythonhosted.org/packages/37/9e/7af078bcfc3cd340c981ac5d613c090ab007023d2ac13b05acd52f22f069/av-15.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:57cc7a733a7e7d7a153682f35c9cf5d01e8269367b049c954779de36fc3d0b10", size = 38027048, upload-time = "2025-08-30T04:40:54.076Z" },
+ { url = "https://files.pythonhosted.org/packages/02/76/1f9dac11ad713e3619288993ea04e9c9cf4ec0f04e5ee81e83b8129dd8f3/av-15.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a77b75bdb6899a64302ff923a5246e0747b3f0a3ecee7d61118db407a22c3f53", size = 39565396, upload-time = "2025-08-30T04:40:57.84Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/32/2188c46e2747247458ffc26b230c57dd28e61f65ff7b9e6223a411af5e98/av-15.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d0a1154ce081f1720082a133cfe12356c59f62dad2b93a7a1844bf1dcd010d85", size = 40015050, upload-time = "2025-08-30T04:41:01.091Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/41/b57fbce9994580619d7574817ece0fe0e7b822cde2af57904549d0150b8d/av-15.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a7bf5a34dee15c86790414fa86a144e6d0dcc788bc83b565fdcbc080b4fbc90", size = 40821225, upload-time = "2025-08-30T04:41:04.349Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/36/e85cd1f0d3369c6764ad422882895d082f7ececb66d3df8aeae3234ef7a6/av-15.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:e30c9a6fd9734784941384a2e25fad3c22881a7682f378914676aa7e795acdb7", size = 31311750, upload-time = "2025-08-30T04:41:07.744Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d8/08a681758a4e49adfda409a6a35eff533f42654c6a6cfa102bc5cae1a728/av-15.1.0-cp314-cp314t-macosx_13_0_arm64.whl", hash = "sha256:60666833d7e65ebcfc48034a072de74349edbb62c9aaa3e6722fef31ca028eb6", size = 21828343, upload-time = "2025-08-30T04:41:10.81Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/52/29bec3fe68669b21f7d1ab5d94e21f597b8dfd37f50a3e3c9af6a8da925c/av-15.1.0-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:53fbdae45aa2a49a22e864ff4f4017416ef62c060a172085d3247ba0a101104e", size = 27001666, upload-time = "2025-08-30T04:41:13.822Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/54/2c1d1faced66d708f5df328e800997cb47f90b500a214130c3a0f2ad601e/av-15.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:e6c51061667983dc801502aff9140bbc4f0e0d97f879586f17fb2f9a7e49c381", size = 39496753, upload-time = "2025-08-30T04:41:16.759Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/76/06ded5e52c4dcc2d9b5184c6da8de5ea77bd7ecb79a59a2b9700f1984949/av-15.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:2f80ec387f04aa34868662b11018b5f09654ae1530a61e24e92a142a24b10b62", size = 40784729, upload-time = "2025-08-30T04:41:20.491Z" },
+ { url = "https://files.pythonhosted.org/packages/52/ef/797b76f3b39c99a96e387f501bbc07dca340b27d3dda12862fe694066b63/av-15.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4975e03177d37d8165c99c8d494175675ba8acb72458fb5d7e43f746a53e0374", size = 41284953, upload-time = "2025-08-30T04:41:23.949Z" },
+ { url = "https://files.pythonhosted.org/packages/31/47/e4656f00e62fd059ea5a40b492dea784f5aecfe1dfac10c0d7a0664ce200/av-15.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8f78f3dad11780b4cdd024cdb92ce43cb170929297c00f2f4555c2b103f51e55", size = 41985340, upload-time = "2025-08-30T04:41:27.561Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/c9/15bb4fd7a1f39d70db35af2b9c20a0ae19e4220eb58a8b8446e903b98d72/av-15.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a20c5eba3ec49c2f4b281797021923fc68a86aeb66c5cda4fd0252fa8004951", size = 31487337, upload-time = "2025-08-30T04:41:30.591Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2025.8.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "1.17.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" },
+ { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" },
+ { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" },
+ { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
+ { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
+ { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" },
+ { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" },
+ { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" },
+ { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" },
+ { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" },
+ { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" },
+ { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
+ { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
+ { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
+ { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
+ { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
+ { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
+ { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
+ { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
+ { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coloredlogs"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "humanfriendly" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
+]
+
+[[package]]
+name = "colorlog"
+version = "6.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d3/7a/359f4d5df2353f26172b3cc39ea32daa39af8de522205f512f458923e677/colorlog-6.9.0.tar.gz", hash = "sha256:bfba54a1b93b94f54e1f4fe48395725a3d92fd2a4af702f6bd70946bdc0c6ac2", size = 16624, upload-time = "2024-10-29T18:34:51.011Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/51/9b208e85196941db2f0654ad0357ca6388ab3ed67efdbfc799f35d1f83aa/colorlog-6.9.0-py3-none-any.whl", hash = "sha256:5906e71acd67cb07a71e779c47c4bcb45fb8c2993eebe9e5adcd6a6f1b283eff", size = 11424, upload-time = "2024-10-29T18:34:49.815Z" },
+]
+
+[[package]]
+name = "contourpy"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" },
+ { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" },
+ { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" },
+ { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" },
+ { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" },
+ { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" },
+ { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" },
+ { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" },
+ { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" },
+ { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" },
+ { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" },
+ { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" },
+ { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" },
+ { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" },
+ { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" },
+ { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" },
+ { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" },
+ { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" },
+ { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" },
+ { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" },
+ { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" },
+ { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" },
+ { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" },
+ { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" },
+ { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" },
+]
+
+[[package]]
+name = "ctranslate2"
+version = "4.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pyyaml" },
+ { name = "setuptools" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/e9/3f1e35528b445b2fc928063f3ddd1ca5ac195b08c28ab10312e599c5cf28/ctranslate2-4.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff3ad05010857d450ee40fd9c28a33c10215a7180e189151e378ed2d19be8a57", size = 13310925, upload-time = "2025-04-08T19:49:47.051Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/72/3880c3be097596a523cb24b52dc0514f685c2ec0bab9cceaeed874aeddec/ctranslate2-4.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78a844c633b6d450b20adac296f7f60ac2a67f2c76e510a83c8916835dc13f04", size = 1297913, upload-time = "2025-04-08T19:49:48.702Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/b3/77af5ad0e896dd27a10db768d7a67b8807e394c8e68c2fa559c662a33547/ctranslate2-4.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44bf4b973ea985b80696093e11e9c72909aee55b35abb749428333822c70ce68", size = 17485132, upload-time = "2025-04-08T19:49:50.076Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/e9/06c2bf49d6808359d71f1126ec5b8e5a5c3c9526899ed58f24666e0e1b86/ctranslate2-4.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b2ca5c2905b540dd833a0b75d912ec9acc18d33a2dc4f85f12032851659a0d", size = 38816537, upload-time = "2025-04-08T19:49:52.735Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/4c/0ecd260233290bee4b2facec4d8e755e57d8781d68f276e1248433993c9f/ctranslate2-4.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:511cdf810a5bf6a2cec735799e5cd47966e63f8f7688fdee1b97fed621abda00", size = 19470040, upload-time = "2025-04-08T19:49:55.274Z" },
+ { url = "https://files.pythonhosted.org/packages/59/96/dea1633368d60eb3da7403f3773cc2ba7988e56044ae155f68ab1ebb8f81/ctranslate2-4.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6283ffe63831b980282ff64ab845c62c7ef771f2ce06cb34825fd7578818bf07", size = 13310770, upload-time = "2025-04-08T19:49:57.238Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/65/d6470f6cfb10e5a065bd71c8cf99d5d107a9d33caedaa622ad7bd9dca01d/ctranslate2-4.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2ebaae12ade184a235569235a875cf03d53b07732342f93b96ae76ef02c31961", size = 1297777, upload-time = "2025-04-08T19:49:59.383Z" },
+ { url = "https://files.pythonhosted.org/packages/13/52/249565849281e7d6c997ffca88447b8806c119e1b0d1f799c27dda061440/ctranslate2-4.6.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a719cd765ec10fe20f9a866093e777a000fd926a0bf235c7921f12c84befb443", size = 17487553, upload-time = "2025-04-08T19:50:00.816Z" },
+ { url = "https://files.pythonhosted.org/packages/77/6d/131193b68d3884f9ab9474d916c6244df2914fbb3234d2a4c1fada72b1d6/ctranslate2-4.6.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:039aa6cc3ed662931a60dec0be28abeaaceb3cc6f476060b8017a7a39a54a9f6", size = 38817828, upload-time = "2025-04-08T19:50:03.445Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/96/37470cbab08464a31877eb80c3ca3f56d097a1616adc982b53c5bf71d2c2/ctranslate2-4.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:af555c75cb9a9cc6c385f38680b92fa426761cf690e4479b1e962e2b17e02972", size = 19470232, upload-time = "2025-04-08T19:50:06.192Z" },
+]
+
+[[package]]
+name = "cycler"
+version = "0.12.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" },
+]
+
+[[package]]
+name = "decorator"
+version = "5.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
+]
+
+[[package]]
+name = "dnspython"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" },
+]
+
+[[package]]
+name = "docopt"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" }
+
+[[package]]
+name = "einops"
+version = "0.8.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/81/df4fbe24dff8ba3934af99044188e20a98ed441ad17a274539b74e82e126/einops-0.8.1.tar.gz", hash = "sha256:de5d960a7a761225532e0f1959e5315ebeafc0cd43394732f103ca44b9837e84", size = 54805, upload-time = "2025-02-09T03:17:00.434Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/62/9773de14fe6c45c23649e98b83231fffd7b9892b6cf863251dc2afa73643/einops-0.8.1-py3-none-any.whl", hash = "sha256:919387eb55330f5757c6bea9165c5ff5cfe63a642682ea788a6d472576d81737", size = 64359, upload-time = "2025-02-09T03:17:01.998Z" },
+]
+
+[[package]]
+name = "email-validator"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dnspython" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.116.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
+]
+
+[package.optional-dependencies]
+standard = [
+ { name = "email-validator" },
+ { name = "fastapi-cli", extra = ["standard"] },
+ { name = "httpx" },
+ { name = "jinja2" },
+ { name = "python-multipart" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[[package]]
+name = "fastapi-cli"
+version = "0.0.10"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "rich-toolkit" },
+ { name = "typer" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/b6/ed25b8874a27f684bf601990c48fcb3edb478edca2b9a38cc2ba196fb304/fastapi_cli-0.0.10.tar.gz", hash = "sha256:85a93df72ff834c3d2a356164512cabaf8f093d50eddad9309065a9c9ac5193a", size = 16994, upload-time = "2025-08-31T17:43:20.702Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/62/0f00036925c0614e333a2baf739c861453a6779331ffb47ec9a6147f860b/fastapi_cli-0.0.10-py3-none-any.whl", hash = "sha256:04bef56b49f7357c6c4acd4f793b4433ed3f511be431ed0af68db6d3f8bd44b3", size = 10851, upload-time = "2025-08-31T17:43:19.481Z" },
+]
+
+[package.optional-dependencies]
+standard = [
+ { name = "fastapi-cloud-cli" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[[package]]
+name = "fastapi-cloud-cli"
+version = "0.1.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "httpx" },
+ { name = "pydantic", extra = ["email"] },
+ { name = "rich-toolkit" },
+ { name = "rignore" },
+ { name = "sentry-sdk" },
+ { name = "typer" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/2e/3b6e5016affc310e5109bc580f760586eabecea0c8a7ab067611cd849ac0/fastapi_cloud_cli-0.1.5.tar.gz", hash = "sha256:341ee585eb731a6d3c3656cb91ad38e5f39809bf1a16d41de1333e38635a7937", size = 22710, upload-time = "2025-07-28T13:30:48.216Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/a6/5aa862489a2918a096166fd98d9fe86b7fd53c607678b3fa9d8c432d88d5/fastapi_cloud_cli-0.1.5-py3-none-any.whl", hash = "sha256:d80525fb9c0e8af122370891f9fa83cf5d496e4ad47a8dd26c0496a6c85a012a", size = 18992, upload-time = "2025-07-28T13:30:47.427Z" },
+]
+
+[[package]]
+name = "faster-whisper"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "av" },
+ { name = "ctranslate2" },
+ { name = "huggingface-hub" },
+ { name = "onnxruntime" },
+ { name = "tokenizers" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/77/c2/72002e5f80e73941de05f7b4347ea183d29f76768978a04acda68401c931/faster-whisper-1.2.0.tar.gz", hash = "sha256:56b20d616a575049a79f33b04f02db0868ce38c5d057a0b816d36ca59a6d2598", size = 1124896, upload-time = "2025-08-06T00:34:10.878Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/6d/64cdc135e4195f9473c2e42aa1d2268654be4c289223828eee8e6ba4fc6d/faster_whisper-1.2.0-py3-none-any.whl", hash = "sha256:e5535628fe93b5123029b410fd8edba2d28f8cee9f8fff8119138e5a9d81afbe", size = 1118581, upload-time = "2025-08-06T00:34:09.476Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.19.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
+]
+
+[[package]]
+name = "flatbuffers"
+version = "25.2.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/30/eb5dce7994fc71a2f685d98ec33cc660c0a5887db5610137e60d8cbc4489/flatbuffers-25.2.10.tar.gz", hash = "sha256:97e451377a41262f8d9bd4295cc836133415cc03d8cb966410a4af92eb00d26e", size = 22170, upload-time = "2025-02-11T04:26:46.257Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b8/25/155f9f080d5e4bc0082edfda032ea2bc2b8fab3f4d25d46c1e9dd22a1a89/flatbuffers-25.2.10-py2.py3-none-any.whl", hash = "sha256:ebba5f4d5ea615af3f7fd70fc310636fbb2bbd1f566ac0a23d98dd412de50051", size = 30953, upload-time = "2025-02-11T04:26:44.484Z" },
+]
+
+[[package]]
+name = "fonttools"
+version = "4.59.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/a5/fba25f9fbdab96e26dedcaeeba125e5f05a09043bf888e0305326e55685b/fonttools-4.59.2.tar.gz", hash = "sha256:e72c0749b06113f50bcb80332364c6be83a9582d6e3db3fe0b280f996dc2ef22", size = 3540889, upload-time = "2025-08-27T16:40:30.97Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/3d/1f45db2df51e7bfa55492e8f23f383d372200be3a0ded4bf56a92753dd1f/fonttools-4.59.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82906d002c349cad647a7634b004825a7335f8159d0d035ae89253b4abf6f3ea", size = 2769711, upload-time = "2025-08-27T16:39:04.423Z" },
+ { url = "https://files.pythonhosted.org/packages/29/df/cd236ab32a8abfd11558f296e064424258db5edefd1279ffdbcfd4fd8b76/fonttools-4.59.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a10c1bd7644dc58f8862d8ba0cf9fb7fef0af01ea184ba6ce3f50ab7dfe74d5a", size = 2340225, upload-time = "2025-08-27T16:39:06.143Z" },
+ { url = "https://files.pythonhosted.org/packages/98/12/b6f9f964fe6d4b4dd4406bcbd3328821c3de1f909ffc3ffa558fe72af48c/fonttools-4.59.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:738f31f23e0339785fd67652a94bc69ea49e413dfdb14dcb8c8ff383d249464e", size = 4912766, upload-time = "2025-08-27T16:39:08.138Z" },
+ { url = "https://files.pythonhosted.org/packages/73/78/82bde2f2d2c306ef3909b927363170b83df96171f74e0ccb47ad344563cd/fonttools-4.59.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ec99f9bdfee9cdb4a9172f9e8fd578cce5feb231f598909e0aecf5418da4f25", size = 4955178, upload-time = "2025-08-27T16:39:10.094Z" },
+ { url = "https://files.pythonhosted.org/packages/92/77/7de766afe2d31dda8ee46d7e479f35c7d48747e558961489a2d6e3a02bd4/fonttools-4.59.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0476ea74161322e08c7a982f83558a2b81b491509984523a1a540baf8611cc31", size = 4897898, upload-time = "2025-08-27T16:39:12.087Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/77/ce0e0b905d62a06415fda9f2b2e109a24a5db54a59502b769e9e297d2242/fonttools-4.59.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95922a922daa1f77cc72611747c156cfb38030ead72436a2c551d30ecef519b9", size = 5049144, upload-time = "2025-08-27T16:39:13.84Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/ea/870d93aefd23fff2e07cbeebdc332527868422a433c64062c09d4d5e7fe6/fonttools-4.59.2-cp312-cp312-win32.whl", hash = "sha256:39ad9612c6a622726a6a130e8ab15794558591f999673f1ee7d2f3d30f6a3e1c", size = 2206473, upload-time = "2025-08-27T16:39:15.854Z" },
+ { url = "https://files.pythonhosted.org/packages/61/c4/e44bad000c4a4bb2e9ca11491d266e857df98ab6d7428441b173f0fe2517/fonttools-4.59.2-cp312-cp312-win_amd64.whl", hash = "sha256:980fd7388e461b19a881d35013fec32c713ffea1fc37aef2f77d11f332dfd7da", size = 2254706, upload-time = "2025-08-27T16:39:17.893Z" },
+ { url = "https://files.pythonhosted.org/packages/13/7b/d0d3b9431642947b5805201fbbbe938a47b70c76685ef1f0cb5f5d7140d6/fonttools-4.59.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:381bde13216ba09489864467f6bc0c57997bd729abfbb1ce6f807ba42c06cceb", size = 2761563, upload-time = "2025-08-27T16:39:20.286Z" },
+ { url = "https://files.pythonhosted.org/packages/76/be/fc5fe58dd76af7127b769b68071dbc32d4b95adc8b58d1d28d42d93c90f2/fonttools-4.59.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f33839aa091f7eef4e9078f5b7ab1b8ea4b1d8a50aeaef9fdb3611bba80869ec", size = 2335671, upload-time = "2025-08-27T16:39:22.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/9f/bf231c2a3fac99d1d7f1d89c76594f158693f981a4aa02be406e9f036832/fonttools-4.59.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6235fc06bcbdb40186f483ba9d5d68f888ea68aa3c8dac347e05a7c54346fbc8", size = 4893967, upload-time = "2025-08-27T16:39:23.664Z" },
+ { url = "https://files.pythonhosted.org/packages/26/a9/d46d2ad4fcb915198504d6727f83aa07f46764c64f425a861aa38756c9fd/fonttools-4.59.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83ad6e5d06ef3a2884c4fa6384a20d6367b5cfe560e3b53b07c9dc65a7020e73", size = 4951986, upload-time = "2025-08-27T16:39:25.379Z" },
+ { url = "https://files.pythonhosted.org/packages/07/90/1cc8d7dd8f707dfeeca472b82b898d3add0ebe85b1f645690dcd128ee63f/fonttools-4.59.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d029804c70fddf90be46ed5305c136cae15800a2300cb0f6bba96d48e770dde0", size = 4891630, upload-time = "2025-08-27T16:39:27.494Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/04/f0345b0d9fe67d65aa8d3f2d4cbf91d06f111bc7b8d802e65914eb06194d/fonttools-4.59.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:95807a3b5e78f2714acaa26a33bc2143005cc05c0217b322361a772e59f32b89", size = 5035116, upload-time = "2025-08-27T16:39:29.406Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/7d/5ba5eefffd243182fbd067cdbfeb12addd4e5aec45011b724c98a344ea33/fonttools-4.59.2-cp313-cp313-win32.whl", hash = "sha256:b3ebda00c3bb8f32a740b72ec38537d54c7c09f383a4cfefb0b315860f825b08", size = 2204907, upload-time = "2025-08-27T16:39:31.42Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/a9/be7219fc64a6026cc0aded17fa3720f9277001c185434230bd351bf678e6/fonttools-4.59.2-cp313-cp313-win_amd64.whl", hash = "sha256:a72155928d7053bbde499d32a9c77d3f0f3d29ae72b5a121752481bcbd71e50f", size = 2253742, upload-time = "2025-08-27T16:39:33.079Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/c7/486580d00be6fa5d45e41682e5ffa5c809f3d25773c6f39628d60f333521/fonttools-4.59.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d09e487d6bfbe21195801323ba95c91cb3523f0fcc34016454d4d9ae9eaa57fe", size = 2762444, upload-time = "2025-08-27T16:39:34.759Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/9b/950ea9b7b764ceb8d18645c62191e14ce62124d8e05cb32a4dc5e65fde0b/fonttools-4.59.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dec2f22486d7781087b173799567cffdcc75e9fb2f1c045f05f8317ccce76a3e", size = 2333256, upload-time = "2025-08-27T16:39:40.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/4d/8ee9d563126de9002eede950cde0051be86cc4e8c07c63eca0c9fc95734a/fonttools-4.59.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1647201af10993090120da2e66e9526c4e20e88859f3e34aa05b8c24ded2a564", size = 4834846, upload-time = "2025-08-27T16:39:42.885Z" },
+ { url = "https://files.pythonhosted.org/packages/03/26/f26d947b0712dce3d118e92ce30ca88f98938b066498f60d0ee000a892ae/fonttools-4.59.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47742c33fe65f41eabed36eec2d7313a8082704b7b808752406452f766c573fc", size = 4930871, upload-time = "2025-08-27T16:39:44.818Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/7f/ebe878061a5a5e6b6502f0548489e01100f7e6c0049846e6546ba19a3ab4/fonttools-4.59.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92ac2d45794f95d1ad4cb43fa07e7e3776d86c83dc4b9918cf82831518165b4b", size = 4876971, upload-time = "2025-08-27T16:39:47.027Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/0d/0d22e3a20ac566836098d30718092351935487e3271fd57385db1adb2fde/fonttools-4.59.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fa9ecaf2dcef8941fb5719e16322345d730f4c40599bbf47c9753de40eb03882", size = 4987478, upload-time = "2025-08-27T16:39:48.774Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/a3/960cc83182a408ffacc795e61b5f698c6f7b0cfccf23da4451c39973f3c8/fonttools-4.59.2-cp314-cp314-win32.whl", hash = "sha256:a8d40594982ed858780e18a7e4c80415af65af0f22efa7de26bdd30bf24e1e14", size = 2208640, upload-time = "2025-08-27T16:39:50.592Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/74/55e5c57c414fa3965fee5fc036ed23f26a5c4e9e10f7f078a54ff9c7dfb7/fonttools-4.59.2-cp314-cp314-win_amd64.whl", hash = "sha256:9cde8b6a6b05f68516573523f2013a3574cb2c75299d7d500f44de82ba947b80", size = 2258457, upload-time = "2025-08-27T16:39:52.611Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/dc/8e4261dc591c5cfee68fecff3ffee2a9b29e1edc4c4d9cbafdc5aefe74ee/fonttools-4.59.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:036cd87a2dbd7ef72f7b68df8314ced00b8d9973aee296f2464d06a836aeb9a9", size = 2829901, upload-time = "2025-08-27T16:39:55.014Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/05/331538dcf21fd6331579cd628268150e85210d0d2bdae20f7598c2b36c05/fonttools-4.59.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:14870930181493b1d740b6f25483e20185e5aea58aec7d266d16da7be822b4bb", size = 2362717, upload-time = "2025-08-27T16:39:56.843Z" },
+ { url = "https://files.pythonhosted.org/packages/60/ae/d26428ca9ede809c0a93f0af91f44c87433dc0251e2aec333da5ed00d38f/fonttools-4.59.2-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7ff58ea1eb8fc7e05e9a949419f031890023f8785c925b44d6da17a6a7d6e85d", size = 4835120, upload-time = "2025-08-27T16:39:59.06Z" },
+ { url = "https://files.pythonhosted.org/packages/07/c4/0f6ac15895de509e07688cb1d45f1ae583adbaa0fa5a5699d73f3bd58ca0/fonttools-4.59.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dee142b8b3096514c96ad9e2106bf039e2fe34a704c587585b569a36df08c3c", size = 5071115, upload-time = "2025-08-27T16:40:01.009Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/b6/147a711b7ecf7ea39f9da9422a55866f6dd5747c2f36b3b0a7a7e0c6820b/fonttools-4.59.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8991bdbae39cf78bcc9cd3d81f6528df1f83f2e7c23ccf6f990fa1f0b6e19708", size = 4943905, upload-time = "2025-08-27T16:40:03.179Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/4e/2ab19006646b753855e2b02200fa1cabb75faa4eeca4ef289f269a936974/fonttools-4.59.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:53c1a411b7690042535a4f0edf2120096a39a506adeb6c51484a232e59f2aa0c", size = 4960313, upload-time = "2025-08-27T16:40:05.45Z" },
+ { url = "https://files.pythonhosted.org/packages/98/3d/df77907e5be88adcca93cc2cee00646d039da220164be12bee028401e1cf/fonttools-4.59.2-cp314-cp314t-win32.whl", hash = "sha256:59d85088e29fa7a8f87d19e97a1beae2a35821ee48d8ef6d2c4f965f26cb9f8a", size = 2269719, upload-time = "2025-08-27T16:40:07.553Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/a0/d4c4bc5b50275449a9a908283b567caa032a94505fe1976e17f994faa6be/fonttools-4.59.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7ad5d8d8cc9e43cb438b3eb4a0094dd6d4088daa767b0a24d52529361fd4c199", size = 2333169, upload-time = "2025-08-27T16:40:09.656Z" },
+ { url = "https://files.pythonhosted.org/packages/65/a4/d2f7be3c86708912c02571db0b550121caab8cd88a3c0aacb9cfa15ea66e/fonttools-4.59.2-py3-none-any.whl", hash = "sha256:8bd0f759020e87bb5d323e6283914d9bf4ae35a7307dafb2cbd1e379e720ad37", size = 1132315, upload-time = "2025-08-27T16:40:28.984Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" },
+ { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" },
+ { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" },
+ { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" },
+ { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" },
+ { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" },
+ { url = "https://files.pythonhosted.org/packages/24/90/6b2cebdabdbd50367273c20ff6b57a3dfa89bd0762de02c3a1eb42cb6462/frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee", size = 79791, upload-time = "2025-06-09T23:01:09.368Z" },
+ { url = "https://files.pythonhosted.org/packages/83/2e/5b70b6a3325363293fe5fc3ae74cdcbc3e996c2a11dde2fd9f1fb0776d19/frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d", size = 47165, upload-time = "2025-06-09T23:01:10.653Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/25/a0895c99270ca6966110f4ad98e87e5662eab416a17e7fd53c364bf8b954/frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43", size = 45881, upload-time = "2025-06-09T23:01:12.296Z" },
+ { url = "https://files.pythonhosted.org/packages/19/7c/71bb0bbe0832793c601fff68cd0cf6143753d0c667f9aec93d3c323f4b55/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d", size = 232409, upload-time = "2025-06-09T23:01:13.641Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/45/ed2798718910fe6eb3ba574082aaceff4528e6323f9a8570be0f7028d8e9/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee", size = 225132, upload-time = "2025-06-09T23:01:15.264Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/e2/8417ae0f8eacb1d071d4950f32f229aa6bf68ab69aab797b72a07ea68d4f/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb", size = 237638, upload-time = "2025-06-09T23:01:16.752Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/b7/2ace5450ce85f2af05a871b8c8719b341294775a0a6c5585d5e6170f2ce7/frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f", size = 233539, upload-time = "2025-06-09T23:01:18.202Z" },
+ { url = "https://files.pythonhosted.org/packages/46/b9/6989292c5539553dba63f3c83dc4598186ab2888f67c0dc1d917e6887db6/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60", size = 215646, upload-time = "2025-06-09T23:01:19.649Z" },
+ { url = "https://files.pythonhosted.org/packages/72/31/bc8c5c99c7818293458fe745dab4fd5730ff49697ccc82b554eb69f16a24/frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00", size = 232233, upload-time = "2025-06-09T23:01:21.175Z" },
+ { url = "https://files.pythonhosted.org/packages/59/52/460db4d7ba0811b9ccb85af996019f5d70831f2f5f255f7cc61f86199795/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b", size = 227996, upload-time = "2025-06-09T23:01:23.098Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c9/f4b39e904c03927b7ecf891804fd3b4df3db29b9e487c6418e37988d6e9d/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c", size = 242280, upload-time = "2025-06-09T23:01:24.808Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/33/3f8d6ced42f162d743e3517781566b8481322be321b486d9d262adf70bfb/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949", size = 217717, upload-time = "2025-06-09T23:01:26.28Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/e8/ad683e75da6ccef50d0ab0c2b2324b32f84fc88ceee778ed79b8e2d2fe2e/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca", size = 236644, upload-time = "2025-06-09T23:01:27.887Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/14/8d19ccdd3799310722195a72ac94ddc677541fb4bef4091d8e7775752360/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b", size = 238879, upload-time = "2025-06-09T23:01:29.524Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/13/c12bf657494c2fd1079a48b2db49fa4196325909249a52d8f09bc9123fd7/frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e", size = 232502, upload-time = "2025-06-09T23:01:31.287Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/8b/e7f9dfde869825489382bc0d512c15e96d3964180c9499efcec72e85db7e/frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1", size = 39169, upload-time = "2025-06-09T23:01:35.503Z" },
+ { url = "https://files.pythonhosted.org/packages/35/89/a487a98d94205d85745080a37860ff5744b9820a2c9acbcdd9440bfddf98/frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba", size = 43219, upload-time = "2025-06-09T23:01:36.784Z" },
+ { url = "https://files.pythonhosted.org/packages/56/d5/5c4cf2319a49eddd9dd7145e66c4866bdc6f3dbc67ca3d59685149c11e0d/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d", size = 84345, upload-time = "2025-06-09T23:01:38.295Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7d/ec2c1e1dc16b85bc9d526009961953df9cec8481b6886debb36ec9107799/frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d", size = 48880, upload-time = "2025-06-09T23:01:39.887Z" },
+ { url = "https://files.pythonhosted.org/packages/69/86/f9596807b03de126e11e7d42ac91e3d0b19a6599c714a1989a4e85eeefc4/frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b", size = 48498, upload-time = "2025-06-09T23:01:41.318Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/cb/df6de220f5036001005f2d726b789b2c0b65f2363b104bbc16f5be8084f8/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146", size = 292296, upload-time = "2025-06-09T23:01:42.685Z" },
+ { url = "https://files.pythonhosted.org/packages/83/1f/de84c642f17c8f851a2905cee2dae401e5e0daca9b5ef121e120e19aa825/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74", size = 273103, upload-time = "2025-06-09T23:01:44.166Z" },
+ { url = "https://files.pythonhosted.org/packages/88/3c/c840bfa474ba3fa13c772b93070893c6e9d5c0350885760376cbe3b6c1b3/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1", size = 292869, upload-time = "2025-06-09T23:01:45.681Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/1c/3efa6e7d5a39a1d5ef0abeb51c48fb657765794a46cf124e5aca2c7a592c/frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1", size = 291467, upload-time = "2025-06-09T23:01:47.234Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/00/d5c5e09d4922c395e2f2f6b79b9a20dab4b67daaf78ab92e7729341f61f6/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384", size = 266028, upload-time = "2025-06-09T23:01:48.819Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/27/72765be905619dfde25a7f33813ac0341eb6b076abede17a2e3fbfade0cb/frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb", size = 284294, upload-time = "2025-06-09T23:01:50.394Z" },
+ { url = "https://files.pythonhosted.org/packages/88/67/c94103a23001b17808eb7dd1200c156bb69fb68e63fcf0693dde4cd6228c/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c", size = 281898, upload-time = "2025-06-09T23:01:52.234Z" },
+ { url = "https://files.pythonhosted.org/packages/42/34/a3e2c00c00f9e2a9db5653bca3fec306349e71aff14ae45ecc6d0951dd24/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65", size = 290465, upload-time = "2025-06-09T23:01:53.788Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/73/f89b7fbce8b0b0c095d82b008afd0590f71ccb3dee6eee41791cf8cd25fd/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3", size = 266385, upload-time = "2025-06-09T23:01:55.769Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/45/e365fdb554159462ca12df54bc59bfa7a9a273ecc21e99e72e597564d1ae/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657", size = 288771, upload-time = "2025-06-09T23:01:57.4Z" },
+ { url = "https://files.pythonhosted.org/packages/00/11/47b6117002a0e904f004d70ec5194fe9144f117c33c851e3d51c765962d0/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104", size = 288206, upload-time = "2025-06-09T23:01:58.936Z" },
+ { url = "https://files.pythonhosted.org/packages/40/37/5f9f3c3fd7f7746082ec67bcdc204db72dad081f4f83a503d33220a92973/frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf", size = 282620, upload-time = "2025-06-09T23:02:00.493Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/31/8fbc5af2d183bff20f21aa743b4088eac4445d2bb1cdece449ae80e4e2d1/frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81", size = 43059, upload-time = "2025-06-09T23:02:02.072Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/ed/41956f52105b8dbc26e457c5705340c67c8cc2b79f394b79bffc09d0e938/frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e", size = 47516, upload-time = "2025-06-09T23:02:03.779Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2025.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/de/e0/bab50af11c2d75c9c4a2a26a5254573c0bd97cea152254401510950486fa/fsspec-2025.9.0.tar.gz", hash = "sha256:19fd429483d25d28b65ec68f9f4adc16c17ea2c7c7bf54ec61360d478fb19c19", size = 304847, upload-time = "2025-09-02T19:10:49.215Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl", hash = "sha256:530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7", size = 199289, upload-time = "2025-09-02T19:10:47.708Z" },
+]
+
+[package.optional-dependencies]
+http = [
+ { name = "aiohttp" },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.2.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" },
+ { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" },
+ { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" },
+ { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" },
+ { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" },
+ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
+ { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
+ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
+ { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
+ { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "hf-xet"
+version = "1.1.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/23/0f/5b60fc28ee7f8cc17a5114a584fd6b86e11c3e0a6e142a7f97a161e9640a/hf_xet-1.1.9.tar.gz", hash = "sha256:c99073ce404462e909f1d5839b2d14a3827b8fe75ed8aed551ba6609c026c803", size = 484242, upload-time = "2025-08-27T23:05:19.441Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/12/56e1abb9a44cdef59a411fe8a8673313195711b5ecce27880eb9c8fa90bd/hf_xet-1.1.9-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a3b6215f88638dd7a6ff82cb4e738dcbf3d863bf667997c093a3c990337d1160", size = 2762553, upload-time = "2025-08-27T23:05:15.153Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/e6/2d0d16890c5f21b862f5df3146519c182e7f0ae49b4b4bf2bd8a40d0b05e/hf_xet-1.1.9-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:9b486de7a64a66f9a172f4b3e0dfe79c9f0a93257c501296a2521a13495a698a", size = 2623216, upload-time = "2025-08-27T23:05:13.778Z" },
+ { url = "https://files.pythonhosted.org/packages/81/42/7e6955cf0621e87491a1fb8cad755d5c2517803cea174229b0ec00ff0166/hf_xet-1.1.9-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4c5a840c2c4e6ec875ed13703a60e3523bc7f48031dfd750923b2a4d1a5fc3c", size = 3186789, upload-time = "2025-08-27T23:05:12.368Z" },
+ { url = "https://files.pythonhosted.org/packages/df/8b/759233bce05457f5f7ec062d63bbfd2d0c740b816279eaaa54be92aa452a/hf_xet-1.1.9-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:96a6139c9e44dad1c52c52520db0fffe948f6bce487cfb9d69c125f254bb3790", size = 3088747, upload-time = "2025-08-27T23:05:10.439Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/3c/28cc4db153a7601a996985bcb564f7b8f5b9e1a706c7537aad4b4809f358/hf_xet-1.1.9-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ad1022e9a998e784c97b2173965d07fe33ee26e4594770b7785a8cc8f922cd95", size = 3251429, upload-time = "2025-08-27T23:05:16.471Z" },
+ { url = "https://files.pythonhosted.org/packages/84/17/7caf27a1d101bfcb05be85850d4aa0a265b2e1acc2d4d52a48026ef1d299/hf_xet-1.1.9-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86754c2d6d5afb11b0a435e6e18911a4199262fe77553f8c50d75e21242193ea", size = 3354643, upload-time = "2025-08-27T23:05:17.828Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/50/0c39c9eed3411deadcc98749a6699d871b822473f55fe472fad7c01ec588/hf_xet-1.1.9-cp37-abi3-win_amd64.whl", hash = "sha256:5aad3933de6b725d61d51034e04174ed1dce7a57c63d530df0014dea15a40127", size = 2804797, upload-time = "2025-08-27T23:05:20.77Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httptools"
+version = "0.6.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" },
+ { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" },
+ { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" },
+ { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" },
+ { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" },
+ { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "huggingface-hub"
+version = "0.34.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/45/c9/bdbe19339f76d12985bc03572f330a01a93c04dffecaaea3061bdd7fb892/huggingface_hub-0.34.4.tar.gz", hash = "sha256:a4228daa6fb001be3f4f4bdaf9a0db00e1739235702848df00885c9b5742c85c", size = 459768, upload-time = "2025-08-08T09:14:52.365Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/7b/bb06b061991107cd8783f300adff3e7b7f284e330fd82f507f2a1417b11d/huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a", size = 561452, upload-time = "2025-08-08T09:14:50.159Z" },
+]
+
+[[package]]
+name = "humanfriendly"
+version = "10.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyreadline3", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
+]
+
+[[package]]
+name = "hyperpyyaml"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyyaml" },
+ { name = "ruamel-yaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/52/e3/3ac46d9a662b037f699a6948b39c8d03bfcff0b592335d5953ba0c55d453/HyperPyYAML-1.2.2.tar.gz", hash = "sha256:bdb734210d18770a262f500fe5755c7a44a5d3b91521b06e24f7a00a36ee0f87", size = 17085, upload-time = "2023-09-21T14:45:27.779Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/c9/751b6401887f4b50f9307cc1e53d287b3dc77c375c126aeb6335aff73ccb/HyperPyYAML-1.2.2-py3-none-any.whl", hash = "sha256:3c5864bdc8864b2f0fbd7bc495e7e8fdf2dfd5dd80116f72da27ca96a128bdeb", size = 16118, upload-time = "2023-09-21T14:45:25.101Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "joblib"
+version = "1.5.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/5d/447af5ea094b9e4c4054f82e223ada074c552335b9b4b2d14bd9b35a67c4/joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55", size = 331077, upload-time = "2025-08-27T12:15:46.575Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241", size = 308396, upload-time = "2025-08-27T12:15:45.188Z" },
+]
+
+[[package]]
+name = "julius"
+version = "0.2.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "torch" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a1/19/c9e1596b5572c786b93428d0904280e964c930fae7e6c9368ed9e1b63922/julius-0.2.7.tar.gz", hash = "sha256:3c0f5f5306d7d6016fcc95196b274cae6f07e2c9596eed314e4e7641554fbb08", size = 59640, upload-time = "2022-09-19T16:13:34.2Z" }
+
+[[package]]
+name = "kiwisolver"
+version = "1.4.9"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" },
+ { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" },
+ { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" },
+ { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" },
+ { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" },
+ { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" },
+ { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" },
+ { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" },
+ { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" },
+ { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" },
+ { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" },
+ { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" },
+ { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" },
+ { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" },
+ { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" },
+ { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" },
+ { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" },
+ { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" },
+ { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" },
+ { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" },
+ { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" },
+ { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" },
+ { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" },
+ { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" },
+ { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" },
+ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" },
+]
+
+[[package]]
+name = "lazy-loader"
+version = "0.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6f/6b/c875b30a1ba490860c93da4cabf479e03f584eba06fe5963f6f6644653d8/lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1", size = 15431, upload-time = "2024-04-05T13:03:12.261Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc", size = 12097, upload-time = "2024-04-05T13:03:10.514Z" },
+]
+
+[[package]]
+name = "librosa"
+version = "0.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "audioread" },
+ { name = "decorator" },
+ { name = "joblib" },
+ { name = "lazy-loader" },
+ { name = "msgpack" },
+ { name = "numba" },
+ { name = "numpy" },
+ { name = "pooch" },
+ { name = "scikit-learn" },
+ { name = "scipy" },
+ { name = "soundfile" },
+ { name = "soxr" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9a/c4/22a644b91098223d653993388daaf9af28175f2f39073269efa6f7c71caf/librosa-0.10.1.tar.gz", hash = "sha256:832f7d150d6dd08ed2aa08c0567a4be58330635c32ddd2208de9bc91300802c7", size = 311110, upload-time = "2023-08-16T13:52:20.7Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/a2/4f639c1168d7aada749a896afb4892a831e2041bebdcf636aebfe9e86556/librosa-0.10.1-py3-none-any.whl", hash = "sha256:7ab91d9f5fcb75ea14848a05d3b1f825cf8d0c42ca160d19ae6874f2de2d8223", size = 253710, upload-time = "2023-08-16T13:52:19.141Z" },
+]
+
+[[package]]
+name = "lightning"
+version = "2.5.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fsspec", extra = ["http"] },
+ { name = "lightning-utilities" },
+ { name = "packaging" },
+ { name = "pytorch-lightning" },
+ { name = "pyyaml" },
+ { name = "torch" },
+ { name = "torchmetrics" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0f/dd/86bb3bebadcdbc6e6e5a63657f0a03f74cd065b5ea965896679f76fec0b4/lightning-2.5.5.tar.gz", hash = "sha256:4d3d66c5b1481364a7e6a1ce8ddde1777a04fa740a3145ec218a9941aed7dd30", size = 640770, upload-time = "2025-09-05T16:01:21.026Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2e/d0/4b4fbafc3b18df91207a6e46782d9fd1905f9f45cb2c3b8dfbb239aef781/lightning-2.5.5-py3-none-any.whl", hash = "sha256:69eb248beadd7b600bf48eff00a0ec8af171ec7a678d23787c4aedf12e225e8f", size = 828490, upload-time = "2025-09-05T16:01:17.845Z" },
+]
+
+[[package]]
+name = "lightning-utilities"
+version = "0.15.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "setuptools" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b8/39/6fc58ca81492db047149b4b8fd385aa1bfb8c28cd7cacb0c7eb0c44d842f/lightning_utilities-0.15.2.tar.gz", hash = "sha256:cdf12f530214a63dacefd713f180d1ecf5d165338101617b4742e8f22c032e24", size = 31090, upload-time = "2025-08-06T13:57:39.242Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/73/3d757cb3fc16f0f9794dd289bcd0c4a031d9cf54d8137d6b984b2d02edf3/lightning_utilities-0.15.2-py3-none-any.whl", hash = "sha256:ad3ab1703775044bbf880dbf7ddaaac899396c96315f3aa1779cec9d618a9841", size = 29431, upload-time = "2025-08-06T13:57:38.046Z" },
+]
+
+[[package]]
+name = "llvmlite"
+version = "0.44.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880, upload-time = "2025-01-20T11:14:41.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/86/e3c3195b92e6e492458f16d233e58a1a812aa2bfbef9bdd0fbafcec85c60/llvmlite-0.44.0-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:1d671a56acf725bf1b531d5ef76b86660a5ab8ef19bb6a46064a705c6ca80aad", size = 28132297, upload-time = "2025-01-20T11:13:32.57Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/53/373b6b8be67b9221d12b24125fd0ec56b1078b660eeae266ec388a6ac9a0/llvmlite-0.44.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f79a728e0435493611c9f405168682bb75ffd1fbe6fc360733b850c80a026db", size = 26201105, upload-time = "2025-01-20T11:13:38.744Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/da/8341fd3056419441286c8e26bf436923021005ece0bff5f41906476ae514/llvmlite-0.44.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0143a5ef336da14deaa8ec26c5449ad5b6a2b564df82fcef4be040b9cacfea9", size = 42361901, upload-time = "2025-01-20T11:13:46.711Z" },
+ { url = "https://files.pythonhosted.org/packages/53/ad/d79349dc07b8a395a99153d7ce8b01d6fcdc9f8231355a5df55ded649b61/llvmlite-0.44.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d752f89e31b66db6f8da06df8b39f9b91e78c5feea1bf9e8c1fba1d1c24c065d", size = 41184247, upload-time = "2025-01-20T11:13:56.159Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3b/a9a17366af80127bd09decbe2a54d8974b6d8b274b39bf47fbaedeec6307/llvmlite-0.44.0-cp312-cp312-win_amd64.whl", hash = "sha256:eae7e2d4ca8f88f89d315b48c6b741dcb925d6a1042da694aa16ab3dd4cbd3a1", size = 30332380, upload-time = "2025-01-20T11:14:02.442Z" },
+ { url = "https://files.pythonhosted.org/packages/89/24/4c0ca705a717514c2092b18476e7a12c74d34d875e05e4d742618ebbf449/llvmlite-0.44.0-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:319bddd44e5f71ae2689859b7203080716448a3cd1128fb144fe5c055219d516", size = 28132306, upload-time = "2025-01-20T11:14:09.035Z" },
+ { url = "https://files.pythonhosted.org/packages/01/cf/1dd5a60ba6aee7122ab9243fd614abcf22f36b0437cbbe1ccf1e3391461c/llvmlite-0.44.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c58867118bad04a0bb22a2e0068c693719658105e40009ffe95c7000fcde88e", size = 26201090, upload-time = "2025-01-20T11:14:15.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/1b/656f5a357de7135a3777bd735cc7c9b8f23b4d37465505bd0eaf4be9befe/llvmlite-0.44.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46224058b13c96af1365290bdfebe9a6264ae62fb79b2b55693deed11657a8bf", size = 42361904, upload-time = "2025-01-20T11:14:22.949Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/e1/12c5f20cb9168fb3464a34310411d5ad86e4163c8ff2d14a2b57e5cc6bac/llvmlite-0.44.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0097052c32bf721a4efc03bd109d335dfa57d9bffb3d4c24cc680711b8b4fc", size = 41184245, upload-time = "2025-01-20T11:14:31.731Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/81/e66fc86539293282fd9cb7c9417438e897f369e79ffb62e1ae5e5154d4dd/llvmlite-0.44.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fb7c4f2fb86cbae6dca3db9ab203eeea0e22d73b99bc2341cdf9de93612e930", size = 30331193, upload-time = "2025-01-20T11:14:38.578Z" },
+]
+
+[[package]]
+name = "mako"
+version = "1.3.10"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" },
+ { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" },
+ { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" },
+ { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
+ { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
+ { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
+ { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
+ { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
+ { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
+]
+
+[[package]]
+name = "matplotlib"
+version = "3.10.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "contourpy" },
+ { name = "cycler" },
+ { name = "fonttools" },
+ { name = "kiwisolver" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pillow" },
+ { name = "pyparsing" },
+ { name = "python-dateutil" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/1a/7042f7430055d567cc3257ac409fcf608599ab27459457f13772c2d9778b/matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347", size = 8272404, upload-time = "2025-08-30T00:12:59.112Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/5d/1d5f33f5b43f4f9e69e6a5fe1fb9090936ae7bc8e2ff6158e7a76542633b/matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75", size = 8128262, upload-time = "2025-08-30T00:13:01.141Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c3/135fdbbbf84e0979712df58e5e22b4f257b3f5e52a3c4aacf1b8abec0d09/matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95", size = 8697008, upload-time = "2025-08-30T00:13:03.24Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/be/c443ea428fb2488a3ea7608714b1bd85a82738c45da21b447dc49e2f8e5d/matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb", size = 9530166, upload-time = "2025-08-30T00:13:05.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/35/48441422b044d74034aea2a3e0d1a49023f12150ebc58f16600132b9bbaf/matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07", size = 9593105, upload-time = "2025-08-30T00:13:08.356Z" },
+ { url = "https://files.pythonhosted.org/packages/45/c3/994ef20eb4154ab84cc08d033834555319e4af970165e6c8894050af0b3c/matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b", size = 8122784, upload-time = "2025-08-30T00:13:10.367Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b8/5c85d9ae0e40f04e71bedb053aada5d6bab1f9b5399a0937afb5d6b02d98/matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa", size = 7992823, upload-time = "2025-08-30T00:13:12.24Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/db/18380e788bb837e724358287b08e223b32bc8dccb3b0c12fa8ca20bc7f3b/matplotlib-3.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:819e409653c1106c8deaf62e6de6b8611449c2cd9939acb0d7d4e57a3d95cc7a", size = 8273231, upload-time = "2025-08-30T00:13:13.881Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/0f/38dd49445b297e0d4f12a322c30779df0d43cb5873c7847df8a82e82ec67/matplotlib-3.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:59c8ac8382fefb9cb71308dde16a7c487432f5255d8f1fd32473523abecfecdf", size = 8128730, upload-time = "2025-08-30T00:13:15.556Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/b8/9eea6630198cb303d131d95d285a024b3b8645b1763a2916fddb44ca8760/matplotlib-3.10.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84e82d9e0fd70c70bc55739defbd8055c54300750cbacf4740c9673a24d6933a", size = 8698539, upload-time = "2025-08-30T00:13:17.297Z" },
+ { url = "https://files.pythonhosted.org/packages/71/34/44c7b1f075e1ea398f88aeabcc2907c01b9cc99e2afd560c1d49845a1227/matplotlib-3.10.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25f7a3eb42d6c1c56e89eacd495661fc815ffc08d9da750bca766771c0fd9110", size = 9529702, upload-time = "2025-08-30T00:13:19.248Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/7f/e5c2dc9950c7facaf8b461858d1b92c09dd0cf174fe14e21953b3dda06f7/matplotlib-3.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9c862d91ec0b7842920a4cfdaaec29662195301914ea54c33e01f1a28d014b2", size = 9593742, upload-time = "2025-08-30T00:13:21.181Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/1d/70c28528794f6410ee2856cd729fa1f1756498b8d3126443b0a94e1a8695/matplotlib-3.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:1b53bd6337eba483e2e7d29c5ab10eee644bc3a2491ec67cc55f7b44583ffb18", size = 8122753, upload-time = "2025-08-30T00:13:23.44Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/74/0e1670501fc7d02d981564caf7c4df42974464625935424ca9654040077c/matplotlib-3.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:cbd5eb50b7058b2892ce45c2f4e92557f395c9991f5c886d1bb74a1582e70fd6", size = 7992973, upload-time = "2025-08-30T00:13:26.632Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/4e/60780e631d73b6b02bd7239f89c451a72970e5e7ec34f621eda55cd9a445/matplotlib-3.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:acc86dd6e0e695c095001a7fccff158c49e45e0758fdf5dcdbb0103318b59c9f", size = 8316869, upload-time = "2025-08-30T00:13:28.262Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/15/baa662374a579413210fc2115d40c503b7360a08e9cc254aa0d97d34b0c1/matplotlib-3.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e228cd2ffb8f88b7d0b29e37f68ca9aaf83e33821f24a5ccc4f082dd8396bc27", size = 8178240, upload-time = "2025-08-30T00:13:30.007Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/3f/3c38e78d2aafdb8829fcd0857d25aaf9e7dd2dfcf7ec742765b585774931/matplotlib-3.10.6-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:658bc91894adeab669cf4bb4a186d049948262987e80f0857216387d7435d833", size = 8711719, upload-time = "2025-08-30T00:13:31.72Z" },
+ { url = "https://files.pythonhosted.org/packages/96/4b/2ec2bbf8cefaa53207cc56118d1fa8a0f9b80642713ea9390235d331ede4/matplotlib-3.10.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8913b7474f6dd83ac444c9459c91f7f0f2859e839f41d642691b104e0af056aa", size = 9541422, upload-time = "2025-08-30T00:13:33.611Z" },
+ { url = "https://files.pythonhosted.org/packages/83/7d/40255e89b3ef11c7871020563b2dd85f6cb1b4eff17c0f62b6eb14c8fa80/matplotlib-3.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:091cea22e059b89f6d7d1a18e2c33a7376c26eee60e401d92a4d6726c4e12706", size = 9594068, upload-time = "2025-08-30T00:13:35.833Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/a9/0213748d69dc842537a113493e1c27daf9f96bd7cc316f933dc8ec4de985/matplotlib-3.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:491e25e02a23d7207629d942c666924a6b61e007a48177fdd231a0097b7f507e", size = 8200100, upload-time = "2025-08-30T00:13:37.668Z" },
+ { url = "https://files.pythonhosted.org/packages/be/15/79f9988066ce40b8a6f1759a934ea0cde8dc4adc2262255ee1bc98de6ad0/matplotlib-3.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3d80d60d4e54cda462e2cd9a086d85cd9f20943ead92f575ce86885a43a565d5", size = 8042142, upload-time = "2025-08-30T00:13:39.426Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/58/e7b6d292beae6fb4283ca6fb7fa47d7c944a68062d6238c07b497dd35493/matplotlib-3.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:70aaf890ce1d0efd482df969b28a5b30ea0b891224bb315810a3940f67182899", size = 8273802, upload-time = "2025-08-30T00:13:41.006Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/f6/7882d05aba16a8cdd594fb9a03a9d3cca751dbb6816adf7b102945522ee9/matplotlib-3.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1565aae810ab79cb72e402b22facfa6501365e73ebab70a0fdfb98488d2c3c0c", size = 8131365, upload-time = "2025-08-30T00:13:42.664Z" },
+ { url = "https://files.pythonhosted.org/packages/94/bf/ff32f6ed76e78514e98775a53715eca4804b12bdcf35902cdd1cf759d324/matplotlib-3.10.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3b23315a01981689aa4e1a179dbf6ef9fbd17143c3eea77548c2ecfb0499438", size = 9533961, upload-time = "2025-08-30T00:13:44.372Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c3/6bf88c2fc2da7708a2ff8d2eeb5d68943130f50e636d5d3dcf9d4252e971/matplotlib-3.10.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30fdd37edf41a4e6785f9b37969de57aea770696cb637d9946eb37470c94a453", size = 9804262, upload-time = "2025-08-30T00:13:46.614Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/7a/e05e6d9446d2d577b459427ad060cd2de5742d0e435db3191fea4fcc7e8b/matplotlib-3.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bc31e693da1c08012c764b053e702c1855378e04102238e6a5ee6a7117c53a47", size = 9595508, upload-time = "2025-08-30T00:13:48.731Z" },
+ { url = "https://files.pythonhosted.org/packages/39/fb/af09c463ced80b801629fd73b96f726c9f6124c3603aa2e480a061d6705b/matplotlib-3.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:05be9bdaa8b242bc6ff96330d18c52f1fc59c6fb3a4dd411d953d67e7e1baf98", size = 8252742, upload-time = "2025-08-30T00:13:50.539Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f9/b682f6db9396d9ab8f050c0a3bfbb5f14fb0f6518f08507c04cc02f8f229/matplotlib-3.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:f56a0d1ab05d34c628592435781d185cd99630bdfd76822cd686fb5a0aecd43a", size = 8124237, upload-time = "2025-08-30T00:13:54.3Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/d2/b69b4a0923a3c05ab90527c60fdec899ee21ca23ede7f0fb818e6620d6f2/matplotlib-3.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:94f0b4cacb23763b64b5dace50d5b7bfe98710fed5f0cef5c08135a03399d98b", size = 8316956, upload-time = "2025-08-30T00:13:55.932Z" },
+ { url = "https://files.pythonhosted.org/packages/28/e9/dc427b6f16457ffaeecb2fc4abf91e5adb8827861b869c7a7a6d1836fa73/matplotlib-3.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cc332891306b9fb39462673d8225d1b824c89783fee82840a709f96714f17a5c", size = 8178260, upload-time = "2025-08-30T00:14:00.942Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/89/1fbd5ad611802c34d1c7ad04607e64a1350b7fb9c567c4ec2c19e066ed35/matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee1d607b3fb1590deb04b69f02ea1d53ed0b0bf75b2b1a5745f269afcbd3cdd3", size = 9541422, upload-time = "2025-08-30T00:14:02.664Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/3b/65fec8716025b22c1d72d5a82ea079934c76a547696eaa55be6866bc89b1/matplotlib-3.10.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:376a624a218116461696b27b2bbf7a8945053e6d799f6502fc03226d077807bf", size = 9803678, upload-time = "2025-08-30T00:14:04.741Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/b0/40fb2b3a1ab9381bb39a952e8390357c8be3bdadcf6d5055d9c31e1b35ae/matplotlib-3.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:83847b47f6524c34b4f2d3ce726bb0541c48c8e7692729865c3df75bfa0f495a", size = 9594077, upload-time = "2025-08-30T00:14:07.012Z" },
+ { url = "https://files.pythonhosted.org/packages/76/34/c4b71b69edf5b06e635eee1ed10bfc73cf8df058b66e63e30e6a55e231d5/matplotlib-3.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c7e0518e0d223683532a07f4b512e2e0729b62674f1b3a1a69869f98e6b1c7e3", size = 8342822, upload-time = "2025-08-30T00:14:09.041Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/62/aeabeef1a842b6226a30d49dd13e8a7a1e81e9ec98212c0b5169f0a12d83/matplotlib-3.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:4dd83e029f5b4801eeb87c64efd80e732452781c16a9cf7415b7b63ec8f374d7", size = 8172588, upload-time = "2025-08-30T00:14:11.166Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+]
+
+[[package]]
+name = "msgpack"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/26/389b9c593eda2b8551b2e7126ad3a06af6f9b44274eb3a4f054d48ff7e47/msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238", size = 82359, upload-time = "2025-06-13T06:52:03.909Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/65/7d1de38c8a22cf8b1551469159d4b6cf49be2126adc2482de50976084d78/msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157", size = 79172, upload-time = "2025-06-13T06:52:05.246Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/bd/cacf208b64d9577a62c74b677e1ada005caa9b69a05a599889d6fc2ab20a/msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce", size = 425013, upload-time = "2025-06-13T06:52:06.341Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/ec/fd869e2567cc9c01278a736cfd1697941ba0d4b81a43e0aa2e8d71dab208/msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a", size = 426905, upload-time = "2025-06-13T06:52:07.501Z" },
+ { url = "https://files.pythonhosted.org/packages/55/2a/35860f33229075bce803a5593d046d8b489d7ba2fc85701e714fc1aaf898/msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c", size = 407336, upload-time = "2025-06-13T06:52:09.047Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/16/69ed8f3ada150bf92745fb4921bd621fd2cdf5a42e25eb50bcc57a5328f0/msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b", size = 409485, upload-time = "2025-06-13T06:52:10.382Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/b6/0c398039e4c6d0b2e37c61d7e0e9d13439f91f780686deb8ee64ecf1ae71/msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef", size = 412182, upload-time = "2025-06-13T06:52:11.644Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d0/0cf4a6ecb9bc960d624c93effaeaae75cbf00b3bc4a54f35c8507273cda1/msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a", size = 419883, upload-time = "2025-06-13T06:52:12.806Z" },
+ { url = "https://files.pythonhosted.org/packages/62/83/9697c211720fa71a2dfb632cad6196a8af3abea56eece220fde4674dc44b/msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c", size = 65406, upload-time = "2025-06-13T06:52:14.271Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/23/0abb886e80eab08f5e8c485d6f13924028602829f63b8f5fa25a06636628/msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4", size = 72558, upload-time = "2025-06-13T06:52:15.252Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" },
+ { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" },
+ { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.6.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/7f/0652e6ed47ab288e3756ea9c0df8b14950781184d4bd7883f4d87dd41245/multidict-6.6.4.tar.gz", hash = "sha256:d2d4e4787672911b48350df02ed3fa3fffdc2f2e8ca06dd6afdf34189b76a9dd", size = 101843, upload-time = "2025-08-11T12:08:48.217Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/f6/512ffd8fd8b37fb2680e5ac35d788f1d71bbaf37789d21a820bdc441e565/multidict-6.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0ffb87be160942d56d7b87b0fdf098e81ed565add09eaa1294268c7f3caac4c8", size = 76516, upload-time = "2025-08-11T12:06:53.393Z" },
+ { url = "https://files.pythonhosted.org/packages/99/58/45c3e75deb8855c36bd66cc1658007589662ba584dbf423d01df478dd1c5/multidict-6.6.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d191de6cbab2aff5de6c5723101705fd044b3e4c7cfd587a1929b5028b9714b3", size = 45394, upload-time = "2025-08-11T12:06:54.555Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ca/e8c4472a93a26e4507c0b8e1f0762c0d8a32de1328ef72fd704ef9cc5447/multidict-6.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:38a0956dd92d918ad5feff3db8fcb4a5eb7dba114da917e1a88475619781b57b", size = 43591, upload-time = "2025-08-11T12:06:55.672Z" },
+ { url = "https://files.pythonhosted.org/packages/05/51/edf414f4df058574a7265034d04c935aa84a89e79ce90fcf4df211f47b16/multidict-6.6.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6865f6d3b7900ae020b495d599fcf3765653bc927951c1abb959017f81ae8287", size = 237215, upload-time = "2025-08-11T12:06:57.213Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/45/8b3d6dbad8cf3252553cc41abea09ad527b33ce47a5e199072620b296902/multidict-6.6.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a2088c126b6f72db6c9212ad827d0ba088c01d951cee25e758c450da732c138", size = 258299, upload-time = "2025-08-11T12:06:58.946Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/e8/8ca2e9a9f5a435fc6db40438a55730a4bf4956b554e487fa1b9ae920f825/multidict-6.6.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0f37bed7319b848097085d7d48116f545985db988e2256b2e6f00563a3416ee6", size = 242357, upload-time = "2025-08-11T12:07:00.301Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/84/80c77c99df05a75c28490b2af8f7cba2a12621186e0a8b0865d8e745c104/multidict-6.6.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:01368e3c94032ba6ca0b78e7ccb099643466cf24f8dc8eefcfdc0571d56e58f9", size = 268369, upload-time = "2025-08-11T12:07:01.638Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/e9/920bfa46c27b05fb3e1ad85121fd49f441492dca2449c5bcfe42e4565d8a/multidict-6.6.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe323540c255db0bffee79ad7f048c909f2ab0edb87a597e1c17da6a54e493c", size = 269341, upload-time = "2025-08-11T12:07:02.943Z" },
+ { url = "https://files.pythonhosted.org/packages/af/65/753a2d8b05daf496f4a9c367fe844e90a1b2cac78e2be2c844200d10cc4c/multidict-6.6.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8eb3025f17b0a4c3cd08cda49acf312a19ad6e8a4edd9dbd591e6506d999402", size = 256100, upload-time = "2025-08-11T12:07:04.564Z" },
+ { url = "https://files.pythonhosted.org/packages/09/54/655be13ae324212bf0bc15d665a4e34844f34c206f78801be42f7a0a8aaa/multidict-6.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bbc14f0365534d35a06970d6a83478b249752e922d662dc24d489af1aa0d1be7", size = 253584, upload-time = "2025-08-11T12:07:05.914Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/74/ab2039ecc05264b5cec73eb018ce417af3ebb384ae9c0e9ed42cb33f8151/multidict-6.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:75aa52fba2d96bf972e85451b99d8e19cc37ce26fd016f6d4aa60da9ab2b005f", size = 251018, upload-time = "2025-08-11T12:07:08.301Z" },
+ { url = "https://files.pythonhosted.org/packages/af/0a/ccbb244ac848e56c6427f2392741c06302bbfba49c0042f1eb3c5b606497/multidict-6.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fefd4a815e362d4f011919d97d7b4a1e566f1dde83dc4ad8cfb5b41de1df68d", size = 251477, upload-time = "2025-08-11T12:07:10.248Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b0/0ed49bba775b135937f52fe13922bc64a7eaf0a3ead84a36e8e4e446e096/multidict-6.6.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:db9801fe021f59a5b375ab778973127ca0ac52429a26e2fd86aa9508f4d26eb7", size = 263575, upload-time = "2025-08-11T12:07:11.928Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d9/7fb85a85e14de2e44dfb6a24f03c41e2af8697a6df83daddb0e9b7569f73/multidict-6.6.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a650629970fa21ac1fb06ba25dabfc5b8a2054fcbf6ae97c758aa956b8dba802", size = 259649, upload-time = "2025-08-11T12:07:13.244Z" },
+ { url = "https://files.pythonhosted.org/packages/03/9e/b3a459bcf9b6e74fa461a5222a10ff9b544cb1cd52fd482fb1b75ecda2a2/multidict-6.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:452ff5da78d4720d7516a3a2abd804957532dd69296cb77319c193e3ffb87e24", size = 251505, upload-time = "2025-08-11T12:07:14.57Z" },
+ { url = "https://files.pythonhosted.org/packages/86/a2/8022f78f041dfe6d71e364001a5cf987c30edfc83c8a5fb7a3f0974cff39/multidict-6.6.4-cp312-cp312-win32.whl", hash = "sha256:8c2fcb12136530ed19572bbba61b407f655e3953ba669b96a35036a11a485793", size = 41888, upload-time = "2025-08-11T12:07:15.904Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/eb/d88b1780d43a56db2cba24289fa744a9d216c1a8546a0dc3956563fd53ea/multidict-6.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:047d9425860a8c9544fed1b9584f0c8bcd31bcde9568b047c5e567a1025ecd6e", size = 46072, upload-time = "2025-08-11T12:07:17.045Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/16/b929320bf5750e2d9d4931835a4c638a19d2494a5b519caaaa7492ebe105/multidict-6.6.4-cp312-cp312-win_arm64.whl", hash = "sha256:14754eb72feaa1e8ae528468f24250dd997b8e2188c3d2f593f9eba259e4b364", size = 43222, upload-time = "2025-08-11T12:07:18.328Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/5d/e1db626f64f60008320aab00fbe4f23fc3300d75892a3381275b3d284580/multidict-6.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f46a6e8597f9bd71b31cc708195d42b634c8527fecbcf93febf1052cacc1f16e", size = 75848, upload-time = "2025-08-11T12:07:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/aa/8b6f548d839b6c13887253af4e29c939af22a18591bfb5d0ee6f1931dae8/multidict-6.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:22e38b2bc176c5eb9c0a0e379f9d188ae4cd8b28c0f53b52bce7ab0a9e534657", size = 45060, upload-time = "2025-08-11T12:07:21.163Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/c6/f5e97e5d99a729bc2aa58eb3ebfa9f1e56a9b517cc38c60537c81834a73f/multidict-6.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5df8afd26f162da59e218ac0eefaa01b01b2e6cd606cffa46608f699539246da", size = 43269, upload-time = "2025-08-11T12:07:22.392Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/31/d54eb0c62516776f36fe67f84a732f97e0b0e12f98d5685bebcc6d396910/multidict-6.6.4-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:49517449b58d043023720aa58e62b2f74ce9b28f740a0b5d33971149553d72aa", size = 237158, upload-time = "2025-08-11T12:07:23.636Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/1c/8a10c1c25b23156e63b12165a929d8eb49a6ed769fdbefb06e6f07c1e50d/multidict-6.6.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9408439537c5afdca05edd128a63f56a62680f4b3c234301055d7a2000220f", size = 257076, upload-time = "2025-08-11T12:07:25.049Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/86/90e20b5771d6805a119e483fd3d1e8393e745a11511aebca41f0da38c3e2/multidict-6.6.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:87a32d20759dc52a9e850fe1061b6e41ab28e2998d44168a8a341b99ded1dba0", size = 240694, upload-time = "2025-08-11T12:07:26.458Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/49/484d3e6b535bc0555b52a0a26ba86e4d8d03fd5587d4936dc59ba7583221/multidict-6.6.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:52e3c8d43cdfff587ceedce9deb25e6ae77daba560b626e97a56ddcad3756879", size = 266350, upload-time = "2025-08-11T12:07:27.94Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/b4/aa4c5c379b11895083d50021e229e90c408d7d875471cb3abf721e4670d6/multidict-6.6.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ad8850921d3a8d8ff6fbef790e773cecfc260bbfa0566998980d3fa8f520bc4a", size = 267250, upload-time = "2025-08-11T12:07:29.303Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e5/5e22c5bf96a64bdd43518b1834c6d95a4922cc2066b7d8e467dae9b6cee6/multidict-6.6.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:497a2954adc25c08daff36f795077f63ad33e13f19bfff7736e72c785391534f", size = 254900, upload-time = "2025-08-11T12:07:30.764Z" },
+ { url = "https://files.pythonhosted.org/packages/17/38/58b27fed927c07035abc02befacab42491e7388ca105e087e6e0215ead64/multidict-6.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:024ce601f92d780ca1617ad4be5ac15b501cc2414970ffa2bb2bbc2bd5a68fa5", size = 252355, upload-time = "2025-08-11T12:07:32.205Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/a1/dad75d23a90c29c02b5d6f3d7c10ab36c3197613be5d07ec49c7791e186c/multidict-6.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a693fc5ed9bdd1c9e898013e0da4dcc640de7963a371c0bd458e50e046bf6438", size = 250061, upload-time = "2025-08-11T12:07:33.623Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/1a/ac2216b61c7f116edab6dc3378cca6c70dc019c9a457ff0d754067c58b20/multidict-6.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:190766dac95aab54cae5b152a56520fd99298f32a1266d66d27fdd1b5ac00f4e", size = 249675, upload-time = "2025-08-11T12:07:34.958Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/79/1916af833b800d13883e452e8e0977c065c4ee3ab7a26941fbfdebc11895/multidict-6.6.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:34d8f2a5ffdceab9dcd97c7a016deb2308531d5f0fced2bb0c9e1df45b3363d7", size = 261247, upload-time = "2025-08-11T12:07:36.588Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/65/d1f84fe08ac44a5fc7391cbc20a7cedc433ea616b266284413fd86062f8c/multidict-6.6.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:59e8d40ab1f5a8597abcef00d04845155a5693b5da00d2c93dbe88f2050f2812", size = 257960, upload-time = "2025-08-11T12:07:39.735Z" },
+ { url = "https://files.pythonhosted.org/packages/13/b5/29ec78057d377b195ac2c5248c773703a6b602e132a763e20ec0457e7440/multidict-6.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:467fe64138cfac771f0e949b938c2e1ada2b5af22f39692aa9258715e9ea613a", size = 250078, upload-time = "2025-08-11T12:07:41.525Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/0e/7e79d38f70a872cae32e29b0d77024bef7834b0afb406ddae6558d9e2414/multidict-6.6.4-cp313-cp313-win32.whl", hash = "sha256:14616a30fe6d0a48d0a48d1a633ab3b8bec4cf293aac65f32ed116f620adfd69", size = 41708, upload-time = "2025-08-11T12:07:43.405Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/34/746696dffff742e97cd6a23da953e55d0ea51fa601fa2ff387b3edcfaa2c/multidict-6.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:40cd05eaeb39e2bc8939451f033e57feaa2ac99e07dbca8afe2be450a4a3b6cf", size = 45912, upload-time = "2025-08-11T12:07:45.082Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/87/3bac136181e271e29170d8d71929cdeddeb77f3e8b6a0c08da3a8e9da114/multidict-6.6.4-cp313-cp313-win_arm64.whl", hash = "sha256:f6eb37d511bfae9e13e82cb4d1af36b91150466f24d9b2b8a9785816deb16605", size = 43076, upload-time = "2025-08-11T12:07:46.746Z" },
+ { url = "https://files.pythonhosted.org/packages/64/94/0a8e63e36c049b571c9ae41ee301ada29c3fee9643d9c2548d7d558a1d99/multidict-6.6.4-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:6c84378acd4f37d1b507dfa0d459b449e2321b3ba5f2338f9b085cf7a7ba95eb", size = 82812, upload-time = "2025-08-11T12:07:48.402Z" },
+ { url = "https://files.pythonhosted.org/packages/25/1a/be8e369dfcd260d2070a67e65dd3990dd635cbd735b98da31e00ea84cd4e/multidict-6.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0e0558693063c75f3d952abf645c78f3c5dfdd825a41d8c4d8156fc0b0da6e7e", size = 48313, upload-time = "2025-08-11T12:07:49.679Z" },
+ { url = "https://files.pythonhosted.org/packages/26/5a/dd4ade298674b2f9a7b06a32c94ffbc0497354df8285f27317c66433ce3b/multidict-6.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3f8e2384cb83ebd23fd07e9eada8ba64afc4c759cd94817433ab8c81ee4b403f", size = 46777, upload-time = "2025-08-11T12:07:51.318Z" },
+ { url = "https://files.pythonhosted.org/packages/89/db/98aa28bc7e071bfba611ac2ae803c24e96dd3a452b4118c587d3d872c64c/multidict-6.6.4-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f996b87b420995a9174b2a7c1a8daf7db4750be6848b03eb5e639674f7963773", size = 229321, upload-time = "2025-08-11T12:07:52.965Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/bc/01ddda2a73dd9d167bd85d0e8ef4293836a8f82b786c63fb1a429bc3e678/multidict-6.6.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc356250cffd6e78416cf5b40dc6a74f1edf3be8e834cf8862d9ed5265cf9b0e", size = 249954, upload-time = "2025-08-11T12:07:54.423Z" },
+ { url = "https://files.pythonhosted.org/packages/06/78/6b7c0f020f9aa0acf66d0ab4eb9f08375bac9a50ff5e3edb1c4ccd59eafc/multidict-6.6.4-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dadf95aa862714ea468a49ad1e09fe00fcc9ec67d122f6596a8d40caf6cec7d0", size = 228612, upload-time = "2025-08-11T12:07:55.914Z" },
+ { url = "https://files.pythonhosted.org/packages/00/44/3faa416f89b2d5d76e9d447296a81521e1c832ad6e40b92f990697b43192/multidict-6.6.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dd57515bebffd8ebd714d101d4c434063322e4fe24042e90ced41f18b6d3395", size = 257528, upload-time = "2025-08-11T12:07:57.371Z" },
+ { url = "https://files.pythonhosted.org/packages/05/5f/77c03b89af0fcb16f018f668207768191fb9dcfb5e3361a5e706a11db2c9/multidict-6.6.4-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:967af5f238ebc2eb1da4e77af5492219fbd9b4b812347da39a7b5f5c72c0fa45", size = 256329, upload-time = "2025-08-11T12:07:58.844Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e9/ed750a2a9afb4f8dc6f13dc5b67b514832101b95714f1211cd42e0aafc26/multidict-6.6.4-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a4c6875c37aae9794308ec43e3530e4aa0d36579ce38d89979bbf89582002bb", size = 247928, upload-time = "2025-08-11T12:08:01.037Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/b5/e0571bc13cda277db7e6e8a532791d4403dacc9850006cb66d2556e649c0/multidict-6.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f683a551e92bdb7fac545b9c6f9fa2aebdeefa61d607510b3533286fcab67f5", size = 245228, upload-time = "2025-08-11T12:08:02.96Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/a3/69a84b0eccb9824491f06368f5b86e72e4af54c3067c37c39099b6687109/multidict-6.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3ba5aaf600edaf2a868a391779f7a85d93bed147854925f34edd24cc70a3e141", size = 235869, upload-time = "2025-08-11T12:08:04.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/9d/28802e8f9121a6a0804fa009debf4e753d0a59969ea9f70be5f5fdfcb18f/multidict-6.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:580b643b7fd2c295d83cad90d78419081f53fd532d1f1eb67ceb7060f61cff0d", size = 243446, upload-time = "2025-08-11T12:08:06.332Z" },
+ { url = "https://files.pythonhosted.org/packages/38/ea/6c98add069b4878c1d66428a5f5149ddb6d32b1f9836a826ac764b9940be/multidict-6.6.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:37b7187197da6af3ee0b044dbc9625afd0c885f2800815b228a0e70f9a7f473d", size = 252299, upload-time = "2025-08-11T12:08:07.931Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/09/8fe02d204473e14c0af3affd50af9078839dfca1742f025cca765435d6b4/multidict-6.6.4-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e1b93790ed0bc26feb72e2f08299691ceb6da5e9e14a0d13cc74f1869af327a0", size = 246926, upload-time = "2025-08-11T12:08:09.467Z" },
+ { url = "https://files.pythonhosted.org/packages/37/3d/7b1e10d774a6df5175ecd3c92bff069e77bed9ec2a927fdd4ff5fe182f67/multidict-6.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a506a77ddee1efcca81ecbeae27ade3e09cdf21a8ae854d766c2bb4f14053f92", size = 243383, upload-time = "2025-08-11T12:08:10.981Z" },
+ { url = "https://files.pythonhosted.org/packages/50/b0/a6fae46071b645ae98786ab738447de1ef53742eaad949f27e960864bb49/multidict-6.6.4-cp313-cp313t-win32.whl", hash = "sha256:f93b2b2279883d1d0a9e1bd01f312d6fc315c5e4c1f09e112e4736e2f650bc4e", size = 47775, upload-time = "2025-08-11T12:08:12.439Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/0a/2436550b1520091af0600dff547913cb2d66fbac27a8c33bc1b1bccd8d98/multidict-6.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:6d46a180acdf6e87cc41dc15d8f5c2986e1e8739dc25dbb7dac826731ef381a4", size = 53100, upload-time = "2025-08-11T12:08:13.823Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ea/43ac51faff934086db9c072a94d327d71b7d8b40cd5dcb47311330929ef0/multidict-6.6.4-cp313-cp313t-win_arm64.whl", hash = "sha256:756989334015e3335d087a27331659820d53ba432befdef6a718398b0a8493ad", size = 45501, upload-time = "2025-08-11T12:08:15.173Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/69/b547032297c7e63ba2af494edba695d781af8a0c6e89e4d06cf848b21d80/multidict-6.6.4-py3-none-any.whl", hash = "sha256:27d8f8e125c07cb954e54d75d04905a9bba8a439c1d84aca94949d4d03d8601c", size = 12313, upload-time = "2025-08-11T12:08:46.891Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" },
+]
+
+[[package]]
+name = "numba"
+version = "0.61.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "llvmlite" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615, upload-time = "2025-04-09T02:58:07.659Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/a0/c6b7b9c615cfa3b98c4c63f4316e3f6b3bbe2387740277006551784218cd/numba-0.61.2-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:34fba9406078bac7ab052efbf0d13939426c753ad72946baaa5bf9ae0ebb8dd2", size = 2776626, upload-time = "2025-04-09T02:57:51.857Z" },
+ { url = "https://files.pythonhosted.org/packages/92/4a/fe4e3c2ecad72d88f5f8cd04e7f7cff49e718398a2fac02d2947480a00ca/numba-0.61.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ddce10009bc097b080fc96876d14c051cc0c7679e99de3e0af59014dab7dfe8", size = 2779287, upload-time = "2025-04-09T02:57:53.658Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/2d/e518df036feab381c23a624dac47f8445ac55686ec7f11083655eb707da3/numba-0.61.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b1bb509d01f23d70325d3a5a0e237cbc9544dd50e50588bc581ba860c213546", size = 3885928, upload-time = "2025-04-09T02:57:55.206Z" },
+ { url = "https://files.pythonhosted.org/packages/10/0f/23cced68ead67b75d77cfcca3df4991d1855c897ee0ff3fe25a56ed82108/numba-0.61.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:48a53a3de8f8793526cbe330f2a39fe9a6638efcbf11bd63f3d2f9757ae345cd", size = 3577115, upload-time = "2025-04-09T02:57:56.818Z" },
+ { url = "https://files.pythonhosted.org/packages/68/1d/ddb3e704c5a8fb90142bf9dc195c27db02a08a99f037395503bfbc1d14b3/numba-0.61.2-cp312-cp312-win_amd64.whl", hash = "sha256:97cf4f12c728cf77c9c1d7c23707e4d8fb4632b46275f8f3397de33e5877af18", size = 2831929, upload-time = "2025-04-09T02:57:58.45Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/f3/0fe4c1b1f2569e8a18ad90c159298d862f96c3964392a20d74fc628aee44/numba-0.61.2-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:3a10a8fc9afac40b1eac55717cece1b8b1ac0b946f5065c89e00bde646b5b154", size = 2771785, upload-time = "2025-04-09T02:57:59.96Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/71/91b277d712e46bd5059f8a5866862ed1116091a7cb03bd2704ba8ebe015f/numba-0.61.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d3bcada3c9afba3bed413fba45845f2fb9cd0d2b27dd58a1be90257e293d140", size = 2773289, upload-time = "2025-04-09T02:58:01.435Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/e0/5ea04e7ad2c39288c0f0f9e8d47638ad70f28e275d092733b5817cf243c9/numba-0.61.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbca73ad81fa196bd53dc12e3aaf1564ae036e0c125f237c7644fe64a4928ab", size = 3893918, upload-time = "2025-04-09T02:58:02.933Z" },
+ { url = "https://files.pythonhosted.org/packages/17/58/064f4dcb7d7e9412f16ecf80ed753f92297e39f399c905389688cf950b81/numba-0.61.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5f154aaea625fb32cfbe3b80c5456d514d416fcdf79733dd69c0df3a11348e9e", size = 3584056, upload-time = "2025-04-09T02:58:04.538Z" },
+ { url = "https://files.pythonhosted.org/packages/af/a4/6d3a0f2d3989e62a18749e1e9913d5fa4910bbb3e3311a035baea6caf26d/numba-0.61.2-cp313-cp313-win_amd64.whl", hash = "sha256:59321215e2e0ac5fa928a8020ab00b8e57cda8a97384963ac0dfa4d4e6aa54e7", size = 2831846, upload-time = "2025-04-09T02:58:06.125Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "1.26.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901, upload-time = "2024-02-05T23:55:32.801Z" },
+ { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868, upload-time = "2024-02-05T23:55:56.28Z" },
+ { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109, upload-time = "2024-02-05T23:56:20.368Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613, upload-time = "2024-02-05T23:56:56.054Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172, upload-time = "2024-02-05T23:57:21.56Z" },
+ { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643, upload-time = "2024-02-05T23:57:56.585Z" },
+ { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803, upload-time = "2024-02-05T23:58:08.963Z" },
+ { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754, upload-time = "2024-02-05T23:58:36.364Z" },
+]
+
+[[package]]
+name = "nvidia-cublas-cu12"
+version = "12.8.4.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-cupti-cu12"
+version = "12.8.90"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-nvrtc-cu12"
+version = "12.8.93"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-runtime-cu12"
+version = "12.8.90"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" },
+]
+
+[[package]]
+name = "nvidia-cudnn-cu12"
+version = "9.10.2.21"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas-cu12" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
+]
+
+[[package]]
+name = "nvidia-cufft-cu12"
+version = "11.3.3.83"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink-cu12" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
+]
+
+[[package]]
+name = "nvidia-cufile-cu12"
+version = "1.13.1.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" },
+]
+
+[[package]]
+name = "nvidia-curand-cu12"
+version = "10.3.9.90"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" },
+]
+
+[[package]]
+name = "nvidia-cusolver-cu12"
+version = "11.7.3.90"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas-cu12" },
+ { name = "nvidia-cusparse-cu12" },
+ { name = "nvidia-nvjitlink-cu12" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
+]
+
+[[package]]
+name = "nvidia-cusparse-cu12"
+version = "12.5.8.93"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink-cu12" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
+]
+
+[[package]]
+name = "nvidia-cusparselt-cu12"
+version = "0.7.1"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" },
+]
+
+[[package]]
+name = "nvidia-nccl-cu12"
+version = "2.27.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/5b/4e4fff7bad39adf89f735f2bc87248c81db71205b62bcc0d5ca5b606b3c3/nvidia_nccl_cu12-2.27.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adf27ccf4238253e0b826bce3ff5fa532d65fc42322c8bfdfaf28024c0fbe039", size = 322364134, upload-time = "2025-06-03T21:58:04.013Z" },
+]
+
+[[package]]
+name = "nvidia-nvjitlink-cu12"
+version = "12.8.93"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" },
+]
+
+[[package]]
+name = "nvidia-nvtx-cu12"
+version = "12.8.90"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
+]
+
+[[package]]
+name = "omegaconf"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "antlr4-python3-runtime" },
+ { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload-time = "2022-12-08T20:59:22.753Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" },
+]
+
+[[package]]
+name = "onnxruntime"
+version = "1.22.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coloredlogs" },
+ { name = "flatbuffers" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "protobuf" },
+ { name = "sympy" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/70/ca2a4d38a5deccd98caa145581becb20c53684f451e89eb3a39915620066/onnxruntime-1.22.1-cp312-cp312-macosx_13_0_universal2.whl", hash = "sha256:a938d11c0dc811badf78e435daa3899d9af38abee950d87f3ab7430eb5b3cf5a", size = 34342883, upload-time = "2025-07-10T19:15:38.223Z" },
+ { url = "https://files.pythonhosted.org/packages/29/e5/00b099b4d4f6223b610421080d0eed9327ef9986785c9141819bbba0d396/onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984cea2a02fcc5dfea44ade9aca9fe0f7a8a2cd6f77c258fc4388238618f3928", size = 14473861, upload-time = "2025-07-10T19:15:42.911Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/50/519828a5292a6ccd8d5cd6d2f72c6b36ea528a2ef68eca69647732539ffa/onnxruntime-1.22.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2d39a530aff1ec8d02e365f35e503193991417788641b184f5b1e8c9a6d5ce8d", size = 16475713, upload-time = "2025-07-10T19:15:45.452Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/54/7139d463bb0a312890c9a5db87d7815d4a8cce9e6f5f28d04f0b55fcb160/onnxruntime-1.22.1-cp312-cp312-win_amd64.whl", hash = "sha256:6a64291d57ea966a245f749eb970f4fa05a64d26672e05a83fdb5db6b7d62f87", size = 12690910, upload-time = "2025-07-10T19:15:47.478Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/39/77cefa829740bd830915095d8408dce6d731b244e24b1f64fe3df9f18e86/onnxruntime-1.22.1-cp313-cp313-macosx_13_0_universal2.whl", hash = "sha256:d29c7d87b6cbed8fecfd09dca471832384d12a69e1ab873e5effbb94adc3e966", size = 34342026, upload-time = "2025-07-10T19:15:50.266Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/a6/444291524cb52875b5de980a6e918072514df63a57a7120bf9dfae3aeed1/onnxruntime-1.22.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460487d83b7056ba98f1f7bac80287224c31d8149b15712b0d6f5078fcc33d0f", size = 14474014, upload-time = "2025-07-10T19:15:53.991Z" },
+ { url = "https://files.pythonhosted.org/packages/87/9d/45a995437879c18beff26eacc2322f4227224d04c6ac3254dce2e8950190/onnxruntime-1.22.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0c37070268ba4e02a1a9d28560cd00cd1e94f0d4f275cbef283854f861a65fa", size = 16475427, upload-time = "2025-07-10T19:15:56.067Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/06/9c765e66ad32a7e709ce4cb6b95d7eaa9cb4d92a6e11ea97c20ffecaf765/onnxruntime-1.22.1-cp313-cp313-win_amd64.whl", hash = "sha256:70980d729145a36a05f74b573435531f55ef9503bcda81fc6c3d6b9306199982", size = 12690841, upload-time = "2025-07-10T19:15:58.337Z" },
+ { url = "https://files.pythonhosted.org/packages/52/8c/02af24ee1c8dce4e6c14a1642a7a56cebe323d2fa01d9a360a638f7e4b75/onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a7980bbc4b7f446bac26c3785652fe8730ed02617d765399e89ac7d44e0f7d", size = 14479333, upload-time = "2025-07-10T19:16:00.544Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/15/d75fd66aba116ce3732bb1050401394c5ec52074c4f7ee18db8838dd4667/onnxruntime-1.22.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e7e823624b015ea879d976cbef8bfaed2f7e2cc233d7506860a76dd37f8f381", size = 16477261, upload-time = "2025-07-10T19:16:03.226Z" },
+]
+
+[[package]]
+name = "optuna"
+version = "4.5.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "alembic" },
+ { name = "colorlog" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "sqlalchemy" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/bcd1e5500de6ec794c085a277e5b624e60b4fac1790681d7cdbde25b93a2/optuna-4.5.0.tar.gz", hash = "sha256:264844da16dad744dea295057d8bc218646129c47567d52c35a201d9f99942ba", size = 472338, upload-time = "2025-08-18T06:49:22.402Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/12/cba81286cbaf0f0c3f0473846cfd992cb240bdcea816bf2ef7de8ed0f744/optuna-4.5.0-py3-none-any.whl", hash = "sha256:5b8a783e84e448b0742501bc27195344a28d2c77bd2feef5b558544d954851b0", size = 400872, upload-time = "2025-08-18T06:49:20.697Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+]
+
+[[package]]
+name = "pandas"
+version = "2.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "python-dateutil" },
+ { name = "pytz" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/8e/0e90233ac205ad182bd6b422532695d2b9414944a280488105d598c70023/pandas-2.3.2.tar.gz", hash = "sha256:ab7b58f8f82706890924ccdfb5f48002b83d2b5a3845976a9fb705d36c34dcdb", size = 4488684, upload-time = "2025-08-21T10:28:29.257Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/db/614c20fb7a85a14828edd23f1c02db58a30abf3ce76f38806155d160313c/pandas-2.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fbb977f802156e7a3f829e9d1d5398f6192375a3e2d1a9ee0803e35fe70a2b9", size = 11587652, upload-time = "2025-08-21T10:27:15.888Z" },
+ { url = "https://files.pythonhosted.org/packages/99/b0/756e52f6582cade5e746f19bad0517ff27ba9c73404607c0306585c201b3/pandas-2.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b9b52693123dd234b7c985c68b709b0b009f4521000d0525f2b95c22f15944b", size = 10717686, upload-time = "2025-08-21T10:27:18.486Z" },
+ { url = "https://files.pythonhosted.org/packages/37/4c/dd5ccc1e357abfeee8353123282de17997f90ff67855f86154e5a13b81e5/pandas-2.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bd281310d4f412733f319a5bc552f86d62cddc5f51d2e392c8787335c994175", size = 11278722, upload-time = "2025-08-21T10:27:21.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/a4/f7edcfa47e0a88cda0be8b068a5bae710bf264f867edfdf7b71584ace362/pandas-2.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96d31a6b4354e3b9b8a2c848af75d31da390657e3ac6f30c05c82068b9ed79b9", size = 11987803, upload-time = "2025-08-21T10:27:23.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/61/1bce4129f93ab66f1c68b7ed1c12bac6a70b1b56c5dab359c6bbcd480b52/pandas-2.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:df4df0b9d02bb873a106971bb85d448378ef14b86ba96f035f50bbd3688456b4", size = 12766345, upload-time = "2025-08-21T10:27:26.6Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/46/80d53de70fee835531da3a1dae827a1e76e77a43ad22a8cd0f8142b61587/pandas-2.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:213a5adf93d020b74327cb2c1b842884dbdd37f895f42dcc2f09d451d949f811", size = 13439314, upload-time = "2025-08-21T10:27:29.213Z" },
+ { url = "https://files.pythonhosted.org/packages/28/30/8114832daff7489f179971dbc1d854109b7f4365a546e3ea75b6516cea95/pandas-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c13b81a9347eb8c7548f53fd9a4f08d4dfe996836543f805c987bafa03317ae", size = 10983326, upload-time = "2025-08-21T10:27:31.901Z" },
+ { url = "https://files.pythonhosted.org/packages/27/64/a2f7bf678af502e16b472527735d168b22b7824e45a4d7e96a4fbb634b59/pandas-2.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c6ecbac99a354a051ef21c5307601093cb9e0f4b1855984a084bfec9302699e", size = 11531061, upload-time = "2025-08-21T10:27:34.647Z" },
+ { url = "https://files.pythonhosted.org/packages/54/4c/c3d21b2b7769ef2f4c2b9299fcadd601efa6729f1357a8dbce8dd949ed70/pandas-2.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c6f048aa0fd080d6a06cc7e7537c09b53be6642d330ac6f54a600c3ace857ee9", size = 10668666, upload-time = "2025-08-21T10:27:37.203Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e2/f775ba76ecfb3424d7f5862620841cf0edb592e9abd2d2a5387d305fe7a8/pandas-2.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0064187b80a5be6f2f9c9d6bdde29372468751dfa89f4211a3c5871854cfbf7a", size = 11332835, upload-time = "2025-08-21T10:27:40.188Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/52/0634adaace9be2d8cac9ef78f05c47f3a675882e068438b9d7ec7ef0c13f/pandas-2.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac8c320bded4718b298281339c1a50fb00a6ba78cb2a63521c39bec95b0209b", size = 12057211, upload-time = "2025-08-21T10:27:43.117Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/9d/2df913f14b2deb9c748975fdb2491da1a78773debb25abbc7cbc67c6b549/pandas-2.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:114c2fe4f4328cf98ce5716d1532f3ab79c5919f95a9cfee81d9140064a2e4d6", size = 12749277, upload-time = "2025-08-21T10:27:45.474Z" },
+ { url = "https://files.pythonhosted.org/packages/87/af/da1a2417026bd14d98c236dba88e39837182459d29dcfcea510b2ac9e8a1/pandas-2.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:48fa91c4dfb3b2b9bfdb5c24cd3567575f4e13f9636810462ffed8925352be5a", size = 13415256, upload-time = "2025-08-21T10:27:49.885Z" },
+ { url = "https://files.pythonhosted.org/packages/22/3c/f2af1ce8840ef648584a6156489636b5692c162771918aa95707c165ad2b/pandas-2.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:12d039facec710f7ba305786837d0225a3444af7bbd9c15c32ca2d40d157ed8b", size = 10982579, upload-time = "2025-08-21T10:28:08.435Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/98/8df69c4097a6719e357dc249bf437b8efbde808038268e584421696cbddf/pandas-2.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c624b615ce97864eb588779ed4046186f967374185c047070545253a52ab2d57", size = 12028163, upload-time = "2025-08-21T10:27:52.232Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/23/f95cbcbea319f349e10ff90db488b905c6883f03cbabd34f6b03cbc3c044/pandas-2.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0cee69d583b9b128823d9514171cabb6861e09409af805b54459bd0c821a35c2", size = 11391860, upload-time = "2025-08-21T10:27:54.673Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1b/6a984e98c4abee22058aa75bfb8eb90dce58cf8d7296f8bc56c14bc330b0/pandas-2.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2319656ed81124982900b4c37f0e0c58c015af9a7bbc62342ba5ad07ace82ba9", size = 11309830, upload-time = "2025-08-21T10:27:56.957Z" },
+ { url = "https://files.pythonhosted.org/packages/15/d5/f0486090eb18dd8710bf60afeaf638ba6817047c0c8ae5c6a25598665609/pandas-2.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b37205ad6f00d52f16b6d09f406434ba928c1a1966e2771006a9033c736d30d2", size = 11883216, upload-time = "2025-08-21T10:27:59.302Z" },
+ { url = "https://files.pythonhosted.org/packages/10/86/692050c119696da19e20245bbd650d8dfca6ceb577da027c3a73c62a047e/pandas-2.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:837248b4fc3a9b83b9c6214699a13f069dc13510a6a6d7f9ba33145d2841a012", size = 12699743, upload-time = "2025-08-21T10:28:02.447Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/d7/612123674d7b17cf345aad0a10289b2a384bff404e0463a83c4a3a59d205/pandas-2.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d2c3554bd31b731cd6490d94a28f3abb8dd770634a9e06eb6d2911b9827db370", size = 13186141, upload-time = "2025-08-21T10:28:05.377Z" },
+]
+
+[[package]]
+name = "pillow"
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" },
+ { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
+ { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
+ { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
+ { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
+ { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
+ { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
+ { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
+ { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
+ { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
+]
+
+[[package]]
+name = "platformdirs"
+version = "4.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
+]
+
+[[package]]
+name = "pooch"
+version = "1.8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "platformdirs" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/77/b3d3e00c696c16cf99af81ef7b1f5fe73bd2a307abca41bd7605429fe6e5/pooch-1.8.2.tar.gz", hash = "sha256:76561f0de68a01da4df6af38e9955c4c9d1a5c90da73f7e40276a5728ec83d10", size = 59353, upload-time = "2024-06-06T16:53:46.224Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/87/77cc11c7a9ea9fd05503def69e3d18605852cd0d4b0d3b8f15bbeb3ef1d1/pooch-1.8.2-py3-none-any.whl", hash = "sha256:3529a57096f7198778a5ceefd5ac3ef0e4d06a6ddaf9fc2d609b806f25302c47", size = 64574, upload-time = "2024-06-06T16:53:44.343Z" },
+]
+
+[[package]]
+name = "primepy"
+version = "1.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/35/77/0cfa1b4697cfb5336f3a96e8bc73327f64610be3a64c97275f1801afb395/primePy-1.3.tar.gz", hash = "sha256:25fd7e25344b0789a5984c75d89f054fcf1f180bef20c998e4befbac92de4669", size = 3914, upload-time = "2018-05-29T17:18:18.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/74/c1/bb7e334135859c3a92ec399bc89293ea73f28e815e35b43929c8db6af030/primePy-1.3-py3-none-any.whl", hash = "sha256:5ed443718765be9bf7e2ff4c56cdff71b42140a15b39d054f9d99f0009e2317a", size = 4040, upload-time = "2018-05-29T17:18:17.53Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" },
+ { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" },
+ { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" },
+ { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" },
+ { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" },
+ { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" },
+ { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/d1/8c747fafa558c603c4ca19d8e20b288aa0c7cda74e9402f50f31eb65267e/propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945", size = 71286, upload-time = "2025-06-09T22:54:54.369Z" },
+ { url = "https://files.pythonhosted.org/packages/61/99/d606cb7986b60d89c36de8a85d58764323b3a5ff07770a99d8e993b3fa73/propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252", size = 42425, upload-time = "2025-06-09T22:54:55.642Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/96/ef98f91bbb42b79e9bb82bdd348b255eb9d65f14dbbe3b1594644c4073f7/propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f", size = 41846, upload-time = "2025-06-09T22:54:57.246Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ad/3f0f9a705fb630d175146cd7b1d2bf5555c9beaed54e94132b21aac098a6/propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33", size = 208871, upload-time = "2025-06-09T22:54:58.975Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/38/2085cda93d2c8b6ec3e92af2c89489a36a5886b712a34ab25de9fbca7992/propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e", size = 215720, upload-time = "2025-06-09T22:55:00.471Z" },
+ { url = "https://files.pythonhosted.org/packages/61/c1/d72ea2dc83ac7f2c8e182786ab0fc2c7bd123a1ff9b7975bee671866fe5f/propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1", size = 215203, upload-time = "2025-06-09T22:55:01.834Z" },
+ { url = "https://files.pythonhosted.org/packages/af/81/b324c44ae60c56ef12007105f1460d5c304b0626ab0cc6b07c8f2a9aa0b8/propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3", size = 206365, upload-time = "2025-06-09T22:55:03.199Z" },
+ { url = "https://files.pythonhosted.org/packages/09/73/88549128bb89e66d2aff242488f62869014ae092db63ccea53c1cc75a81d/propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1", size = 196016, upload-time = "2025-06-09T22:55:04.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/3f/3bdd14e737d145114a5eb83cb172903afba7242f67c5877f9909a20d948d/propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6", size = 205596, upload-time = "2025-06-09T22:55:05.942Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ca/2f4aa819c357d3107c3763d7ef42c03980f9ed5c48c82e01e25945d437c1/propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387", size = 200977, upload-time = "2025-06-09T22:55:07.792Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/4a/e65276c7477533c59085251ae88505caf6831c0e85ff8b2e31ebcbb949b1/propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4", size = 197220, upload-time = "2025-06-09T22:55:09.173Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/54/fc7152e517cf5578278b242396ce4d4b36795423988ef39bb8cd5bf274c8/propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88", size = 210642, upload-time = "2025-06-09T22:55:10.62Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/80/abeb4a896d2767bf5f1ea7b92eb7be6a5330645bd7fb844049c0e4045d9d/propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206", size = 212789, upload-time = "2025-06-09T22:55:12.029Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/db/ea12a49aa7b2b6d68a5da8293dcf50068d48d088100ac016ad92a6a780e6/propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43", size = 205880, upload-time = "2025-06-09T22:55:13.45Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/e5/9076a0bbbfb65d1198007059c65639dfd56266cf8e477a9707e4b1999ff4/propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02", size = 37220, upload-time = "2025-06-09T22:55:15.284Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/f5/b369e026b09a26cd77aa88d8fffd69141d2ae00a2abaaf5380d2603f4b7f/propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05", size = 40678, upload-time = "2025-06-09T22:55:16.445Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/3a/6ece377b55544941a08d03581c7bc400a3c8cd3c2865900a68d5de79e21f/propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b", size = 76560, upload-time = "2025-06-09T22:55:17.598Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/da/64a2bb16418740fa634b0e9c3d29edff1db07f56d3546ca2d86ddf0305e1/propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0", size = 44676, upload-time = "2025-06-09T22:55:18.922Z" },
+ { url = "https://files.pythonhosted.org/packages/36/7b/f025e06ea51cb72c52fb87e9b395cced02786610b60a3ed51da8af017170/propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e", size = 44701, upload-time = "2025-06-09T22:55:20.106Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/00/faa1b1b7c3b74fc277f8642f32a4c72ba1d7b2de36d7cdfb676db7f4303e/propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28", size = 276934, upload-time = "2025-06-09T22:55:21.5Z" },
+ { url = "https://files.pythonhosted.org/packages/74/ab/935beb6f1756e0476a4d5938ff44bf0d13a055fed880caf93859b4f1baf4/propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a", size = 278316, upload-time = "2025-06-09T22:55:22.918Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/9d/994a5c1ce4389610838d1caec74bdf0e98b306c70314d46dbe4fcf21a3e2/propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c", size = 282619, upload-time = "2025-06-09T22:55:24.651Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/00/a10afce3d1ed0287cef2e09506d3be9822513f2c1e96457ee369adb9a6cd/propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725", size = 265896, upload-time = "2025-06-09T22:55:26.049Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/a8/2aa6716ffa566ca57c749edb909ad27884680887d68517e4be41b02299f3/propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892", size = 252111, upload-time = "2025-06-09T22:55:27.381Z" },
+ { url = "https://files.pythonhosted.org/packages/36/4f/345ca9183b85ac29c8694b0941f7484bf419c7f0fea2d1e386b4f7893eed/propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44", size = 268334, upload-time = "2025-06-09T22:55:28.747Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/ca/fcd54f78b59e3f97b3b9715501e3147f5340167733d27db423aa321e7148/propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe", size = 255026, upload-time = "2025-06-09T22:55:30.184Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/95/8e6a6bbbd78ac89c30c225210a5c687790e532ba4088afb8c0445b77ef37/propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81", size = 250724, upload-time = "2025-06-09T22:55:31.646Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/b0/0dd03616142baba28e8b2d14ce5df6631b4673850a3d4f9c0f9dd714a404/propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba", size = 268868, upload-time = "2025-06-09T22:55:33.209Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/98/2c12407a7e4fbacd94ddd32f3b1e3d5231e77c30ef7162b12a60e2dd5ce3/propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770", size = 271322, upload-time = "2025-06-09T22:55:35.065Z" },
+ { url = "https://files.pythonhosted.org/packages/35/91/9cb56efbb428b006bb85db28591e40b7736847b8331d43fe335acf95f6c8/propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330", size = 265778, upload-time = "2025-06-09T22:55:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/4c/b0fe775a2bdd01e176b14b574be679d84fc83958335790f7c9a686c1f468/propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394", size = 41175, upload-time = "2025-06-09T22:55:38.436Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/ff/47f08595e3d9b5e149c150f88d9714574f1a7cbd89fe2817158a952674bf/propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198", size = 44857, upload-time = "2025-06-09T22:55:39.687Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "6.32.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/df/fb4a8eeea482eca989b51cffd274aac2ee24e825f0bf3cbce5281fa1567b/protobuf-6.32.0.tar.gz", hash = "sha256:a81439049127067fc49ec1d36e25c6ee1d1a2b7be930675f919258d03c04e7d2", size = 440614, upload-time = "2025-08-14T21:21:25.015Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/18/df8c87da2e47f4f1dcc5153a81cd6bca4e429803f4069a299e236e4dd510/protobuf-6.32.0-cp310-abi3-win32.whl", hash = "sha256:84f9e3c1ff6fb0308dbacb0950d8aa90694b0d0ee68e75719cb044b7078fe741", size = 424409, upload-time = "2025-08-14T21:21:12.366Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/59/0a820b7310f8139bd8d5a9388e6a38e1786d179d6f33998448609296c229/protobuf-6.32.0-cp310-abi3-win_amd64.whl", hash = "sha256:a8bdbb2f009cfc22a36d031f22a625a38b615b5e19e558a7b756b3279723e68e", size = 435735, upload-time = "2025-08-14T21:21:15.046Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/5b/0d421533c59c789e9c9894683efac582c06246bf24bb26b753b149bd88e4/protobuf-6.32.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d52691e5bee6c860fff9a1c86ad26a13afbeb4b168cd4445c922b7e2cf85aaf0", size = 426449, upload-time = "2025-08-14T21:21:16.687Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/7b/607764ebe6c7a23dcee06e054fd1de3d5841b7648a90fd6def9a3bb58c5e/protobuf-6.32.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:501fe6372fd1c8ea2a30b4d9be8f87955a64d6be9c88a973996cef5ef6f0abf1", size = 322869, upload-time = "2025-08-14T21:21:18.282Z" },
+ { url = "https://files.pythonhosted.org/packages/40/01/2e730bd1c25392fc32e3268e02446f0d77cb51a2c3a8486b1798e34d5805/protobuf-6.32.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:75a2aab2bd1aeb1f5dc7c5f33bcb11d82ea8c055c9becbb41c26a8c43fd7092c", size = 322009, upload-time = "2025-08-14T21:21:19.893Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/f2/80ffc4677aac1bc3519b26bc7f7f5de7fce0ee2f7e36e59e27d8beb32dd1/protobuf-6.32.0-py3-none-any.whl", hash = "sha256:ba377e5b67b908c8f3072a57b63e2c6a4cbd18aea4ed98d2584350dbf46f2783", size = 169287, upload-time = "2025-08-14T21:21:23.515Z" },
+]
+
+[[package]]
+name = "pyannote-audio"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "asteroid-filterbanks" },
+ { name = "einops" },
+ { name = "huggingface-hub" },
+ { name = "lightning" },
+ { name = "omegaconf" },
+ { name = "pyannote-core" },
+ { name = "pyannote-database" },
+ { name = "pyannote-metrics" },
+ { name = "pyannote-pipeline" },
+ { name = "pytorch-metric-learning" },
+ { name = "rich" },
+ { name = "semver" },
+ { name = "soundfile" },
+ { name = "speechbrain" },
+ { name = "tensorboardx" },
+ { name = "torch" },
+ { name = "torch-audiomentations" },
+ { name = "torchaudio" },
+ { name = "torchmetrics" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ad/55/7253267c35e2aa9188b1d86cba121eb5bdd91ed12d3194488625a008cae7/pyannote.audio-3.1.0.tar.gz", hash = "sha256:da04705443d3b74607e034d3ca88f8b572c7e9672dd9a4199cab65a0dbc33fad", size = 14812058, upload-time = "2023-11-16T12:26:38.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/37/158859ce4c45b5ba2dca40b53b0c10d36f935b7f6d4e737298397167c8b1/pyannote.audio-3.1.0-py2.py3-none-any.whl", hash = "sha256:66ab485728c6e141760e80555cb7a083e7be824cd528cc79b9e6f7d6421a91ae", size = 208592, upload-time = "2023-11-16T12:26:36.726Z" },
+]
+
+[[package]]
+name = "pyannote-core"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "scipy" },
+ { name = "sortedcontainers" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/65/03/feaf7534206f02c75baf151ce4b8c322b402a6f477c2be82f69d9269cbe6/pyannote.core-5.0.0.tar.gz", hash = "sha256:1a55bcc8bd680ba6be5fa53efa3b6f3d2cdd67144c07b6b4d8d66d5cb0d2096f", size = 59247, upload-time = "2022-12-15T13:02:05.312Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/84/c4/370bc8ba66815a5832ece753a1009388bb07ea353d21c83f2d5a1a436f2c/pyannote.core-5.0.0-py3-none-any.whl", hash = "sha256:04920a6754492242ce0dc6017545595ab643870fe69a994f20c1a5f2da0544d0", size = 58475, upload-time = "2022-12-15T13:02:03.265Z" },
+]
+
+[[package]]
+name = "pyannote-database"
+version = "5.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pandas" },
+ { name = "pyannote-core" },
+ { name = "pyyaml" },
+ { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/ae/de36413d69a46be87cb612ebbcdc4eacbeebce3bc809124603e44a88fe26/pyannote.database-5.1.3.tar.gz", hash = "sha256:0eaf64c1cc506718de60d2d702f1359b1ae7ff252ee3e4799f1c5e378cd52c31", size = 49957, upload-time = "2025-01-15T20:28:26.437Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/64/92d51a3a05615ba58be8ba62a43f9f9f952d9f3646f7e4fb7826e5a3a24e/pyannote.database-5.1.3-py3-none-any.whl", hash = "sha256:37887844c7dfbcc075cb591eddc00aff45fae1ed905344e1f43e0090e63bd40a", size = 48127, upload-time = "2025-01-15T20:28:25.326Z" },
+]
+
+[[package]]
+name = "pyannote-metrics"
+version = "3.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docopt" },
+ { name = "matplotlib" },
+ { name = "numpy" },
+ { name = "pandas" },
+ { name = "pyannote-core" },
+ { name = "pyannote-database" },
+ { name = "scikit-learn" },
+ { name = "scipy" },
+ { name = "sympy" },
+ { name = "tabulate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/39/2b/6c5f01d3c49aa1c160765946e23782ca6436ae8b9bc514b56319ff5f16e7/pyannote.metrics-3.2.1.tar.gz", hash = "sha256:08024255a3550e96a8e9da4f5f4af326886548480de891414567c8900920ee5c", size = 49086, upload-time = "2022-06-20T14:10:34.618Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/7d/035b370ab834b30e849fe9cd092b7bd7f321fcc4a2c56b84e96476b7ede5/pyannote.metrics-3.2.1-py3-none-any.whl", hash = "sha256:46be797cdade26c82773e5018659ae610145260069c7c5bf3d3c8a029ade8e22", size = 51386, upload-time = "2022-06-20T14:10:32.621Z" },
+]
+
+[[package]]
+name = "pyannote-pipeline"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docopt" },
+ { name = "filelock" },
+ { name = "optuna" },
+ { name = "pyannote-core" },
+ { name = "pyannote-database" },
+ { name = "pyyaml" },
+ { name = "scikit-learn" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/35/04/4bcfe0dd588577a188328b806f3a7213d8cead0ce5fe5784d01fd57df93f/pyannote.pipeline-3.0.1.tar.gz", hash = "sha256:021794e26a2cf5d8fb5bb1835951e71f5fac33eb14e23dfb7468e16b1b805151", size = 34486, upload-time = "2023-09-22T20:16:49.951Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/42/1bf7cbf061ed05c580bfb63bffdd3f3474cbd5c02bee4fac518eea9e9d9e/pyannote.pipeline-3.0.1-py3-none-any.whl", hash = "sha256:819bde4c4dd514f740f2373dfec794832b9fc8e346a35e43a7681625ee187393", size = 31517, upload-time = "2023-09-22T20:16:48.153Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "2.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.11.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
+]
+
+[package.optional-dependencies]
+email = [
+ { name = "email-validator" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.33.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
+ { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
+ { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
+ { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
+ { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
+ { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
+ { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
+ { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
+ { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
+ { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
+]
+
+[[package]]
+name = "pyparsing"
+version = "3.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" },
+]
+
+[[package]]
+name = "pyreadline3"
+version = "3.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839, upload-time = "2024-09-19T02:40:10.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178, upload-time = "2024-09-19T02:40:08.598Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
+]
+
+[[package]]
+name = "pytorch-lightning"
+version = "2.5.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "fsspec", extra = ["http"] },
+ { name = "lightning-utilities" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "torch" },
+ { name = "torchmetrics" },
+ { name = "tqdm" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/16/78/bce84aab9a5b3b2e9d087d4f1a6be9b481adbfaac4903bc9daaaf09d49a3/pytorch_lightning-2.5.5.tar.gz", hash = "sha256:d6fc8173d1d6e49abfd16855ea05d2eb2415e68593f33d43e59028ecb4e64087", size = 643703, upload-time = "2025-09-05T16:01:18.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/f6/99a5c66478f469598dee25b0e29b302b5bddd4e03ed0da79608ac964056e/pytorch_lightning-2.5.5-py3-none-any.whl", hash = "sha256:0b533991df2353c0c6ea9ca10a7d0728b73631fd61f5a15511b19bee2aef8af0", size = 832431, upload-time = "2025-09-05T16:01:16.234Z" },
+]
+
+[[package]]
+name = "pytorch-metric-learning"
+version = "2.9.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "scikit-learn" },
+ { name = "torch" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/80/6e61b1a91debf4c1b47d441f9a9d7fe2aabcdd9575ed70b2811474eb95c3/pytorch-metric-learning-2.9.0.tar.gz", hash = "sha256:27a626caf5e2876a0fd666605a78cb67ef7597e25d7a68c18053dd503830701f", size = 84530, upload-time = "2025-08-17T17:11:19.501Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/7d/73ef5052f57b7720cad00e16598db3592a5ef4826745ffca67a2f085d4dc/pytorch_metric_learning-2.9.0-py3-none-any.whl", hash = "sha256:d51646006dc87168f00cf954785db133a4c5aac81253877248737aa42ef6432a", size = 127801, upload-time = "2025-08-17T17:11:18.185Z" },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
+ { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
+ { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
+ { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
+]
+
+[[package]]
+name = "reflector-gpu"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "fastapi", extra = ["standard"] },
+ { name = "faster-whisper" },
+ { name = "librosa" },
+ { name = "numpy" },
+ { name = "pyannote-audio" },
+ { name = "sentencepiece" },
+ { name = "silero-vad" },
+ { name = "torch" },
+ { name = "torchaudio" },
+ { name = "transformers" },
+ { name = "uvicorn", extra = ["standard"] },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "fastapi", extras = ["standard"], specifier = ">=0.116.1" },
+ { name = "faster-whisper", specifier = ">=1.1.0" },
+ { name = "librosa", specifier = "==0.10.1" },
+ { name = "numpy", specifier = "<2" },
+ { name = "pyannote-audio", specifier = "==3.1.0" },
+ { name = "sentencepiece" },
+ { name = "silero-vad", specifier = "==5.1.0" },
+ { name = "torch", specifier = ">=2.3.0" },
+ { name = "torchaudio", specifier = ">=2.3.0" },
+ { name = "transformers", specifier = ">=4.35.0" },
+ { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
+]
+
+[[package]]
+name = "regex"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/5a/4c63457fbcaf19d138d72b2e9b39405954f98c0349b31c601bfcb151582c/regex-2025.9.1.tar.gz", hash = "sha256:88ac07b38d20b54d79e704e38aa3bd2c0f8027432164226bdee201a1c0c9c9ff", size = 400852, upload-time = "2025-09-01T22:10:10.479Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/ef/a0372febc5a1d44c1be75f35d7e5aff40c659ecde864d7fa10e138f75e74/regex-2025.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:84a25164bd8dcfa9f11c53f561ae9766e506e580b70279d05a7946510bdd6f6a", size = 486317, upload-time = "2025-09-01T22:08:34.529Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/25/d64543fb7eb41a1024786d518cc57faf1ce64aa6e9ddba097675a0c2f1d2/regex-2025.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:645e88a73861c64c1af558dd12294fb4e67b5c1eae0096a60d7d8a2143a611c7", size = 289698, upload-time = "2025-09-01T22:08:36.162Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/dc/fbf31fc60be317bd9f6f87daa40a8a9669b3b392aa8fe4313df0a39d0722/regex-2025.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10a450cba5cd5409526ee1d4449f42aad38dd83ac6948cbd6d7f71ca7018f7db", size = 287242, upload-time = "2025-09-01T22:08:37.794Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/74/f933a607a538f785da5021acf5323961b4620972e2c2f1f39b6af4b71db7/regex-2025.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9dc5991592933a4192c166eeb67b29d9234f9c86344481173d1bc52f73a7104", size = 797441, upload-time = "2025-09-01T22:08:39.108Z" },
+ { url = "https://files.pythonhosted.org/packages/89/d0/71fc49b4f20e31e97f199348b8c4d6e613e7b6a54a90eb1b090c2b8496d7/regex-2025.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a32291add816961aab472f4fad344c92871a2ee33c6c219b6598e98c1f0108f2", size = 862654, upload-time = "2025-09-01T22:08:40.586Z" },
+ { url = "https://files.pythonhosted.org/packages/59/05/984edce1411a5685ba9abbe10d42cdd9450aab4a022271f9585539788150/regex-2025.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:588c161a68a383478e27442a678e3b197b13c5ba51dbba40c1ccb8c4c7bee9e9", size = 910862, upload-time = "2025-09-01T22:08:42.416Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/02/5c891bb5fe0691cc1bad336e3a94b9097fbcf9707ec8ddc1dce9f0397289/regex-2025.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47829ffaf652f30d579534da9085fe30c171fa2a6744a93d52ef7195dc38218b", size = 801991, upload-time = "2025-09-01T22:08:44.072Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/ae/fd10d6ad179910f7a1b3e0a7fde1ef8bb65e738e8ac4fd6ecff3f52252e4/regex-2025.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e978e5a35b293ea43f140c92a3269b6ab13fe0a2bf8a881f7ac740f5a6ade85", size = 786651, upload-time = "2025-09-01T22:08:46.079Z" },
+ { url = "https://files.pythonhosted.org/packages/30/cf/9d686b07bbc5bf94c879cc168db92542d6bc9fb67088d03479fef09ba9d3/regex-2025.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cf09903e72411f4bf3ac1eddd624ecfd423f14b2e4bf1c8b547b72f248b7bf7", size = 856556, upload-time = "2025-09-01T22:08:48.376Z" },
+ { url = "https://files.pythonhosted.org/packages/91/9d/302f8a29bb8a49528abbab2d357a793e2a59b645c54deae0050f8474785b/regex-2025.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d016b0f77be63e49613c9e26aaf4a242f196cd3d7a4f15898f5f0ab55c9b24d2", size = 849001, upload-time = "2025-09-01T22:08:50.067Z" },
+ { url = "https://files.pythonhosted.org/packages/93/fa/b4c6dbdedc85ef4caec54c817cd5f4418dbfa2453214119f2538082bf666/regex-2025.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:656563e620de6908cd1c9d4f7b9e0777e3341ca7db9d4383bcaa44709c90281e", size = 788138, upload-time = "2025-09-01T22:08:51.933Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/1b/91ee17a3cbf87f81e8c110399279d0e57f33405468f6e70809100f2ff7d8/regex-2025.9.1-cp312-cp312-win32.whl", hash = "sha256:df33f4ef07b68f7ab637b1dbd70accbf42ef0021c201660656601e8a9835de45", size = 264524, upload-time = "2025-09-01T22:08:53.75Z" },
+ { url = "https://files.pythonhosted.org/packages/92/28/6ba31cce05b0f1ec6b787921903f83bd0acf8efde55219435572af83c350/regex-2025.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5aba22dfbc60cda7c0853516104724dc904caa2db55f2c3e6e984eb858d3edf3", size = 275489, upload-time = "2025-09-01T22:08:55.037Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/ed/ea49f324db00196e9ef7fe00dd13c6164d5173dd0f1bbe495e61bb1fb09d/regex-2025.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:ec1efb4c25e1849c2685fa95da44bfde1b28c62d356f9c8d861d4dad89ed56e9", size = 268589, upload-time = "2025-09-01T22:08:56.369Z" },
+ { url = "https://files.pythonhosted.org/packages/98/25/b2959ce90c6138c5142fe5264ee1f9b71a0c502ca4c7959302a749407c79/regex-2025.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bc6834727d1b98d710a63e6c823edf6ffbf5792eba35d3fa119531349d4142ef", size = 485932, upload-time = "2025-09-01T22:08:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2e/6507a2a85f3f2be6643438b7bd976e67ad73223692d6988eb1ff444106d3/regex-2025.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c3dc05b6d579875719bccc5f3037b4dc80433d64e94681a0061845bd8863c025", size = 289568, upload-time = "2025-09-01T22:08:59.258Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/d8/de4a4b57215d99868f1640e062a7907e185ec7476b4b689e2345487c1ff4/regex-2025.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22213527df4c985ec4a729b055a8306272d41d2f45908d7bacb79be0fa7a75ad", size = 286984, upload-time = "2025-09-01T22:09:00.835Z" },
+ { url = "https://files.pythonhosted.org/packages/03/15/e8cb403403a57ed316e80661db0e54d7aa2efcd85cb6156f33cc18746922/regex-2025.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e3f6e3c5a5a1adc3f7ea1b5aec89abfc2f4fbfba55dafb4343cd1d084f715b2", size = 797514, upload-time = "2025-09-01T22:09:02.538Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/26/2446f2b9585fed61faaa7e2bbce3aca7dd8df6554c32addee4c4caecf24a/regex-2025.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcb89c02a0d6c2bec9b0bb2d8c78782699afe8434493bfa6b4021cc51503f249", size = 862586, upload-time = "2025-09-01T22:09:04.322Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/b8/82ffbe9c0992c31bbe6ae1c4b4e21269a5df2559102b90543c9b56724c3c/regex-2025.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0e2f95413eb0c651cd1516a670036315b91b71767af83bc8525350d4375ccba", size = 910815, upload-time = "2025-09-01T22:09:05.978Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d8/7303ea38911759c1ee30cc5bc623ee85d3196b733c51fd6703c34290a8d9/regex-2025.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09a41dc039e1c97d3c2ed3e26523f748e58c4de3ea7a31f95e1cf9ff973fff5a", size = 802042, upload-time = "2025-09-01T22:09:07.865Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/0e/6ad51a55ed4b5af512bb3299a05d33309bda1c1d1e1808fa869a0bed31bc/regex-2025.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f0b4258b161094f66857a26ee938d3fe7b8a5063861e44571215c44fbf0e5df", size = 786764, upload-time = "2025-09-01T22:09:09.362Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/d5/394e3ffae6baa5a9217bbd14d96e0e5da47bb069d0dbb8278e2681a2b938/regex-2025.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bf70e18ac390e6977ea7e56f921768002cb0fa359c4199606c7219854ae332e0", size = 856557, upload-time = "2025-09-01T22:09:11.129Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/80/b288d3910c41194ad081b9fb4b371b76b0bbfdce93e7709fc98df27b37dc/regex-2025.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b84036511e1d2bb0a4ff1aec26951caa2dea8772b223c9e8a19ed8885b32dbac", size = 849108, upload-time = "2025-09-01T22:09:12.877Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/cd/5ec76bf626d0d5abdc277b7a1734696f5f3d14fbb4a3e2540665bc305d85/regex-2025.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c2e05dcdfe224047f2a59e70408274c325d019aad96227ab959403ba7d58d2d7", size = 788201, upload-time = "2025-09-01T22:09:14.561Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/36/674672f3fdead107565a2499f3007788b878188acec6d42bc141c5366c2c/regex-2025.9.1-cp313-cp313-win32.whl", hash = "sha256:3b9a62107a7441b81ca98261808fed30ae36ba06c8b7ee435308806bd53c1ed8", size = 264508, upload-time = "2025-09-01T22:09:16.193Z" },
+ { url = "https://files.pythonhosted.org/packages/83/ad/931134539515eb64ce36c24457a98b83c1b2e2d45adf3254b94df3735a76/regex-2025.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:b38afecc10c177eb34cfae68d669d5161880849ba70c05cbfbe409f08cc939d7", size = 275469, upload-time = "2025-09-01T22:09:17.462Z" },
+ { url = "https://files.pythonhosted.org/packages/24/8c/96d34e61c0e4e9248836bf86d69cb224fd222f270fa9045b24e218b65604/regex-2025.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:ec329890ad5e7ed9fc292858554d28d58d56bf62cf964faf0aa57964b21155a0", size = 268586, upload-time = "2025-09-01T22:09:18.948Z" },
+ { url = "https://files.pythonhosted.org/packages/21/b1/453cbea5323b049181ec6344a803777914074b9726c9c5dc76749966d12d/regex-2025.9.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:72fb7a016467d364546f22b5ae86c45680a4e0de6b2a6f67441d22172ff641f1", size = 486111, upload-time = "2025-09-01T22:09:20.734Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/0e/92577f197bd2f7652c5e2857f399936c1876978474ecc5b068c6d8a79c86/regex-2025.9.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c9527fa74eba53f98ad86be2ba003b3ebe97e94b6eb2b916b31b5f055622ef03", size = 289520, upload-time = "2025-09-01T22:09:22.249Z" },
+ { url = "https://files.pythonhosted.org/packages/af/c6/b472398116cca7ea5a6c4d5ccd0fc543f7fd2492cb0c48d2852a11972f73/regex-2025.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c905d925d194c83a63f92422af7544ec188301451b292c8b487f0543726107ca", size = 287215, upload-time = "2025-09-01T22:09:23.657Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/11/f12ecb0cf9ca792a32bb92f758589a84149017467a544f2f6bfb45c0356d/regex-2025.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74df7c74a63adcad314426b1f4ea6054a5ab25d05b0244f0c07ff9ce640fa597", size = 797855, upload-time = "2025-09-01T22:09:25.197Z" },
+ { url = "https://files.pythonhosted.org/packages/46/88/bbb848f719a540fb5997e71310f16f0b33a92c5d4b4d72d4311487fff2a3/regex-2025.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4f6e935e98ea48c7a2e8be44494de337b57a204470e7f9c9c42f912c414cd6f5", size = 863363, upload-time = "2025-09-01T22:09:26.705Z" },
+ { url = "https://files.pythonhosted.org/packages/54/a9/2321eb3e2838f575a78d48e03c1e83ea61bd08b74b7ebbdeca8abc50fc25/regex-2025.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4a62d033cd9ebefc7c5e466731a508dfabee827d80b13f455de68a50d3c2543d", size = 910202, upload-time = "2025-09-01T22:09:28.906Z" },
+ { url = "https://files.pythonhosted.org/packages/33/07/d1d70835d7d11b7e126181f316f7213c4572ecf5c5c97bdbb969fb1f38a2/regex-2025.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef971ebf2b93bdc88d8337238be4dfb851cc97ed6808eb04870ef67589415171", size = 801808, upload-time = "2025-09-01T22:09:30.733Z" },
+ { url = "https://files.pythonhosted.org/packages/13/d1/29e4d1bed514ef2bf3a4ead3cb8bb88ca8af94130239a4e68aa765c35b1c/regex-2025.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d936a1db208bdca0eca1f2bb2c1ba1d8370b226785c1e6db76e32a228ffd0ad5", size = 786824, upload-time = "2025-09-01T22:09:32.61Z" },
+ { url = "https://files.pythonhosted.org/packages/33/27/20d8ccb1bee460faaa851e6e7cc4cfe852a42b70caa1dca22721ba19f02f/regex-2025.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:7e786d9e4469698fc63815b8de08a89165a0aa851720eb99f5e0ea9d51dd2b6a", size = 857406, upload-time = "2025-09-01T22:09:34.117Z" },
+ { url = "https://files.pythonhosted.org/packages/74/fe/60c6132262dc36430d51e0c46c49927d113d3a38c1aba6a26c7744c84cf3/regex-2025.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6b81d7dbc5466ad2c57ce3a0ddb717858fe1a29535c8866f8514d785fdb9fc5b", size = 848593, upload-time = "2025-09-01T22:09:35.598Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/ae/2d4ff915622fabbef1af28387bf71e7f2f4944a348b8460d061e85e29bf0/regex-2025.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cd4890e184a6feb0ef195338a6ce68906a8903a0f2eb7e0ab727dbc0a3156273", size = 787951, upload-time = "2025-09-01T22:09:37.139Z" },
+ { url = "https://files.pythonhosted.org/packages/85/37/dc127703a9e715a284cc2f7dbdd8a9776fd813c85c126eddbcbdd1ca5fec/regex-2025.9.1-cp314-cp314-win32.whl", hash = "sha256:34679a86230e46164c9e0396b56cab13c0505972343880b9e705083cc5b8ec86", size = 269833, upload-time = "2025-09-01T22:09:39.245Z" },
+ { url = "https://files.pythonhosted.org/packages/83/bf/4bed4d3d0570e16771defd5f8f15f7ea2311edcbe91077436d6908956c4a/regex-2025.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:a1196e530a6bfa5f4bde029ac5b0295a6ecfaaffbfffede4bbaf4061d9455b70", size = 278742, upload-time = "2025-09-01T22:09:40.651Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/3e/7d7ac6fd085023312421e0d69dfabdfb28e116e513fadbe9afe710c01893/regex-2025.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:f46d525934871ea772930e997d577d48c6983e50f206ff7b66d4ac5f8941e993", size = 271860, upload-time = "2025-09-01T22:09:42.413Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
+]
+
+[[package]]
+name = "rich"
+version = "14.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" },
+]
+
+[[package]]
+name = "rich-toolkit"
+version = "0.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rich" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322, upload-time = "2025-09-04T09:28:11.789Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412, upload-time = "2025-09-04T09:28:10.587Z" },
+]
+
+[[package]]
+name = "rignore"
+version = "0.6.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/46/05a94dc55ac03cf931d18e43b86ecee5ee054cb88b7853fffd741e35009c/rignore-0.6.4.tar.gz", hash = "sha256:e893fdd2d7fdcfa9407d0b7600ef2c2e2df97f55e1c45d4a8f54364829ddb0ab", size = 11633, upload-time = "2025-07-19T19:24:46.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/6c/e5af4383cdd7829ef9aa63ac82a6507983e02dbc7c2e7b9aa64b7b8e2c7a/rignore-0.6.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:74720d074b79f32449d5d212ce732e0144a294a184246d1f1e7bcc1fc5c83b69", size = 885885, upload-time = "2025-07-19T19:23:53.236Z" },
+ { url = "https://files.pythonhosted.org/packages/89/3e/1b02a868830e464769aa417ee195ac352fe71ff818df8ce50c4b998edb9c/rignore-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a8184fcf567bd6b6d7b85a0c138d98dd40f63054141c96b175844414c5530d7", size = 819736, upload-time = "2025-07-19T19:23:46.565Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/75/b9be0c523d97c09f3c6508a67ce376aba4efe41c333c58903a0d7366439a/rignore-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcb0d7d7ecc3fbccf6477bb187c04a091579ea139f15f139abe0b3b48bdfef69", size = 892779, upload-time = "2025-07-19T19:22:35.167Z" },
+ { url = "https://files.pythonhosted.org/packages/91/f4/3064b06233697f2993485d132f06fe95061fef71631485da75aed246c4fd/rignore-0.6.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feac73377a156fb77b3df626c76f7e5893d9b4e9e886ac8c0f9d44f1206a2a91", size = 872116, upload-time = "2025-07-19T19:22:47.828Z" },
+ { url = "https://files.pythonhosted.org/packages/99/94/cb8e7af9a3c0a665f10e2366144e0ebc66167cf846aca5f1ac31b3661598/rignore-0.6.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:465179bc30beb1f7a3439e428739a2b5777ed26660712b8c4e351b15a7c04483", size = 1163345, upload-time = "2025-07-19T19:23:00.557Z" },
+ { url = "https://files.pythonhosted.org/packages/86/6b/49faa7ad85ceb6ccef265df40091d9992232d7f6055fa664fe0a8b13781c/rignore-0.6.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4a4877b4dca9cf31a4d09845b300c677c86267657540d0b4d3e6d0ce3110e6e9", size = 939967, upload-time = "2025-07-19T19:23:13.494Z" },
+ { url = "https://files.pythonhosted.org/packages/80/c8/b91afda10bd5ca1e3a80463340b899c0dc26a7750a9f3c94f668585c7f40/rignore-0.6.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456456802b1e77d1e2d149320ee32505b8183e309e228129950b807d204ddd17", size = 949717, upload-time = "2025-07-19T19:23:36.404Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/f1/88bfdde58ae3fb1c1a92bb801f492eea8eafcdaf05ab9b75130023a4670b/rignore-0.6.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c1ff2fc223f1d9473d36923160af37bf765548578eb9d47a2f52e90da8ae408", size = 975534, upload-time = "2025-07-19T19:23:25.988Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/8f/a80b4a2e48ceba56ba19e096d41263d844757e10aa36ede212571b5d8117/rignore-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e445fbc214ae18e0e644a78086ea5d0f579e210229a4fbe86367d11a4cd03c11", size = 1067837, upload-time = "2025-07-19T19:23:59.888Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/90/0905597af0e78748909ef58418442a480ddd93e9fc89b0ca9ab170c357c0/rignore-0.6.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e07d9c5270fc869bc431aadcfb6ed0447f89b8aafaa666914c077435dc76a123", size = 1134959, upload-time = "2025-07-19T19:24:12.396Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/7d/0fa29adf9183b61947ce6dc8a1a9779a8ea16573f557be28ec893f6ddbaa/rignore-0.6.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7a6ccc0ea83d2c0c6df6b166f2acacedcc220a516436490f41e99a5ae73b6019", size = 1109708, upload-time = "2025-07-19T19:24:24.176Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/a7/92892ed86b2e36da403dd3a0187829f2d880414cef75bd612bfdf4dedebc/rignore-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:536392c5ec91755db48389546c833c4ab1426fe03e5a8522992b54ef8a244e7e", size = 1120546, upload-time = "2025-07-19T19:24:36.377Z" },
+ { url = "https://files.pythonhosted.org/packages/31/1b/d29ae1fe901d523741d6d1d3ffe0d630734dd0ed6b047628a69c1e15ea44/rignore-0.6.4-cp312-cp312-win32.whl", hash = "sha256:f5f9dca46fc41c0a1e236767f68be9d63bdd2726db13a0ae3a30f68414472969", size = 642005, upload-time = "2025-07-19T19:24:56.671Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/41/a224944824688995374e4525115ce85fecd82442fc85edd5bcd81f4f256d/rignore-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:e02eecb9e1b9f9bf7c9030ae73308a777bed3b2486204cc74dfcfbe699ab1497", size = 720358, upload-time = "2025-07-19T19:24:49.959Z" },
+ { url = "https://files.pythonhosted.org/packages/db/a3/edd7d0d5cc0720de132b6651cef95ee080ce5fca11c77d8a47db848e5f90/rignore-0.6.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2b3b1e266ce45189240d14dfa1057f8013ea34b9bc8b3b44125ec8d25fdb3985", size = 885304, upload-time = "2025-07-19T19:23:54.268Z" },
+ { url = "https://files.pythonhosted.org/packages/93/a1/d8d2fb97a6548307507d049b7e93885d4a0dfa1c907af5983fd9f9362a21/rignore-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45fe803628cc14714df10e8d6cdc23950a47eb9eb37dfea9a4779f4c672d2aa0", size = 818799, upload-time = "2025-07-19T19:23:47.544Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/cd/949981fcc180ad5ba7b31c52e78b74b2dea6b7bf744ad4c0c4b212f6da78/rignore-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e439f034277a947a4126e2da79dbb43e33d73d7c09d3d72a927e02f8a16f59aa", size = 892024, upload-time = "2025-07-19T19:22:36.18Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/d3/9042d701a8062d9c88f87760bbc2695ee2c23b3f002d34486b72a85f8efe/rignore-0.6.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84b5121650ae24621154c7bdba8b8970b0739d8146505c9f38e0cda9385d1004", size = 871430, upload-time = "2025-07-19T19:22:49.62Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/50/3370249b984212b7355f3d9241aa6d02e706067c6d194a2614dfbc0f5b27/rignore-0.6.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:52b0957b585ab48a445cf8ac1dbc33a272ab060835e583b4f95aa8c67c23fb2b", size = 1160559, upload-time = "2025-07-19T19:23:01.629Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/6f/2ad7f925838091d065524f30a8abda846d1813eee93328febf262b5cda21/rignore-0.6.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:50359e0d5287b5e2743bd2f2fbf05df619c8282fd3af12f6628ff97b9675551d", size = 939947, upload-time = "2025-07-19T19:23:14.608Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/01/626ec94d62475ae7ef8b00ef98cea61cbea52a389a666703c97c4673d406/rignore-0.6.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efe18096dcb1596757dfe0b412aab6d32564473ae7ee58dea0a8b4be5b1a2e3b", size = 949471, upload-time = "2025-07-19T19:23:37.521Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/c3/699c4f03b3c46f4b5c02f17a0a339225da65aad547daa5b03001e7c6a382/rignore-0.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b79c212d9990a273ad91e8d9765e1766ef6ecedd3be65375d786a252762ba385", size = 974912, upload-time = "2025-07-19T19:23:27.13Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/35/04626c12f9f92a9fc789afc2be32838a5d9b23b6fa8b2ad4a8625638d15b/rignore-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c6ffa7f2a8894c65aa5dc4e8ac8bbdf39a326c0c6589efd27686cfbb48f0197d", size = 1067281, upload-time = "2025-07-19T19:24:01.016Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/9c/8f17baf3b984afea151cb9094716f6f1fb8e8737db97fc6eb6d494bd0780/rignore-0.6.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a63f5720dffc8d8fb0a4d02fafb8370a4031ebf3f99a4e79f334a91e905b7349", size = 1134414, upload-time = "2025-07-19T19:24:13.534Z" },
+ { url = "https://files.pythonhosted.org/packages/10/88/ef84ffa916a96437c12cefcc39d474122da9626d75e3a2ebe09ec5d32f1b/rignore-0.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ce33982da47ac5dc09d19b04fa8d7c9aa6292fc0bd1ecf33076989faa8886094", size = 1109330, upload-time = "2025-07-19T19:24:25.303Z" },
+ { url = "https://files.pythonhosted.org/packages/27/43/2ada5a2ec03b82e903610a1c483f516f78e47700ee6db9823f739e08b3af/rignore-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d899621867aa266824fbd9150e298f19d25b93903ef0133c09f70c65a3416eca", size = 1120381, upload-time = "2025-07-19T19:24:37.798Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/99/e7bcc643085131cb14dbea772def72bf1f6fe9037171ebe177c4f228abc8/rignore-0.6.4-cp313-cp313-win32.whl", hash = "sha256:d0615a6bf4890ec5a90b5fb83666822088fbd4e8fcd740c386fcce51e2f6feea", size = 641761, upload-time = "2025-07-19T19:24:58.096Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/25/7798908044f27dea1a8abdc75c14523e33770137651e5f775a15143f4218/rignore-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:145177f0e32716dc2f220b07b3cde2385b994b7ea28d5c96fbec32639e9eac6f", size = 719876, upload-time = "2025-07-19T19:24:51.125Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/e3/ae1e30b045bf004ad77bbd1679b9afff2be8edb166520921c6f29420516a/rignore-0.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e55bf8f9bbd186f58ab646b4a08718c77131d28a9004e477612b0cbbd5202db2", size = 891776, upload-time = "2025-07-19T19:22:37.78Z" },
+ { url = "https://files.pythonhosted.org/packages/45/a9/1193e3bc23ca0e6eb4f17cf4b99971237f97cfa6f241d98366dff90a6d09/rignore-0.6.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2521f7bf3ee1f2ab22a100a3a4eed39a97b025804e5afe4323528e9ce8f084a5", size = 871442, upload-time = "2025-07-19T19:22:50.972Z" },
+ { url = "https://files.pythonhosted.org/packages/20/83/4c52ae429a0b2e1ce667e35b480e9a6846f9468c443baeaed5d775af9485/rignore-0.6.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cc35773a8a9c119359ef974d0856988d4601d4daa6f532c05f66b4587cf35bc", size = 1159844, upload-time = "2025-07-19T19:23:02.751Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/2f/c740f5751f464c937bfe252dc15a024ae081352cfe80d94aa16d6a617482/rignore-0.6.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b665b1ea14457d7b49e834baabc635a3b8c10cfb5cca5c21161fabdbfc2b850e", size = 939456, upload-time = "2025-07-19T19:23:15.72Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/dd/68dbb08ac0edabf44dd144ff546a3fb0253c5af708e066847df39fc9188f/rignore-0.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c7fd339f344a8548724f289495b835bed7b81174a0bc1c28c6497854bd8855db", size = 1067070, upload-time = "2025-07-19T19:24:02.803Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/3a/7e7ea6f0d31d3f5beb0f2cf2c4c362672f5f7f125714458673fc579e2bed/rignore-0.6.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:91dc94b1cc5af8d6d25ce6edd29e7351830f19b0a03b75cb3adf1f76d00f3007", size = 1134598, upload-time = "2025-07-19T19:24:15.039Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/06/1b3307f6437d29bede5a95738aa89e6d910ba68d4054175c9f60d8e2c6b1/rignore-0.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4d1918221a249e5342b60fd5fa513bf3d6bf272a8738e66023799f0c82ecd788", size = 1108862, upload-time = "2025-07-19T19:24:26.765Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/d5/b37c82519f335f2c472a63fc6215c6f4c51063ecf3166e3acf508011afbd/rignore-0.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:240777332b859dc89dcba59ab6e3f1e062bc8e862ffa3e5f456e93f7fd5cb415", size = 1120002, upload-time = "2025-07-19T19:24:38.952Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/72/2f05559ed5e69bdfdb56ea3982b48e6c0017c59f7241f7e1c5cae992b347/rignore-0.6.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66b0e548753e55cc648f1e7b02d9f74285fe48bb49cec93643d31e563773ab3f", size = 949454, upload-time = "2025-07-19T19:23:38.664Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/92/186693c8f838d670510ac1dfb35afbe964320fbffb343ba18f3d24441941/rignore-0.6.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6971ac9fdd5a0bd299a181096f091c4f3fd286643adceba98eccc03c688a6637", size = 974663, upload-time = "2025-07-19T19:23:28.24Z" },
+]
+
+[[package]]
+name = "ruamel-yaml"
+version = "0.18.15"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ruamel-yaml-clib", marker = "python_full_version < '3.14' and platform_python_implementation == 'CPython'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" },
+]
+
+[[package]]
+name = "ruamel-yaml-clib"
+version = "0.2.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/84/80203abff8ea4993a87d823a5f632e4d92831ef75d404c9fc78d0176d2b5/ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f", size = 225315, upload-time = "2024-10-20T10:10:56.22Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/41/e7a405afbdc26af961678474a55373e1b323605a4f5e2ddd4a80ea80f628/ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632", size = 133433, upload-time = "2024-10-20T10:12:55.657Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/b0/b850385604334c2ce90e3ee1013bd911aedf058a934905863a6ea95e9eb4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:943f32bc9dedb3abff9879edc134901df92cfce2c3d5c9348f172f62eb2d771d", size = 647362, upload-time = "2024-10-20T10:12:57.155Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d0/3f68a86e006448fb6c005aee66565b9eb89014a70c491d70c08de597f8e4/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c3829bb364fdb8e0332c9931ecf57d9be3519241323c5274bd82f709cebc0c", size = 754118, upload-time = "2024-10-20T10:12:58.501Z" },
+ { url = "https://files.pythonhosted.org/packages/52/a9/d39f3c5ada0a3bb2870d7db41901125dbe2434fa4f12ca8c5b83a42d7c53/ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd", size = 706497, upload-time = "2024-10-20T10:13:00.211Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/fa/097e38135dadd9ac25aecf2a54be17ddf6e4c23e43d538492a90ab3d71c6/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31", size = 698042, upload-time = "2024-10-21T11:26:46.038Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/d5/a659ca6f503b9379b930f13bc6b130c9f176469b73b9834296822a83a132/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680", size = 745831, upload-time = "2024-10-21T11:26:47.487Z" },
+ { url = "https://files.pythonhosted.org/packages/db/5d/36619b61ffa2429eeaefaab4f3374666adf36ad8ac6330d855848d7d36fd/ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d", size = 715692, upload-time = "2024-12-11T19:58:17.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/82/85cb92f15a4231c89b95dfe08b09eb6adca929ef7df7e17ab59902b6f589/ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5", size = 98777, upload-time = "2024-10-20T10:13:01.395Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/8f/c3654f6f1ddb75daf3922c3d8fc6005b1ab56671ad56ffb874d908bfa668/ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4", size = 115523, upload-time = "2024-10-20T10:13:02.768Z" },
+ { url = "https://files.pythonhosted.org/packages/29/00/4864119668d71a5fa45678f380b5923ff410701565821925c69780356ffa/ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a", size = 132011, upload-time = "2024-10-20T10:13:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/5e/212f473a93ae78c669ffa0cb051e3fee1139cb2d385d2ae1653d64281507/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:e7e3736715fbf53e9be2a79eb4db68e4ed857017344d697e8b9749444ae57475", size = 642488, upload-time = "2024-10-20T10:13:05.906Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/8f/ecfbe2123ade605c49ef769788f79c38ddb1c8fa81e01f4dbf5cf1a44b16/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b7e75b4965e1d4690e93021adfcecccbca7d61c7bddd8e22406ef2ff20d74ef", size = 745066, upload-time = "2024-10-20T10:13:07.26Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a9/28f60726d29dfc01b8decdb385de4ced2ced9faeb37a847bd5cf26836815/ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6", size = 701785, upload-time = "2024-10-20T10:13:08.504Z" },
+ { url = "https://files.pythonhosted.org/packages/84/7e/8e7ec45920daa7f76046578e4f677a3215fe8f18ee30a9cb7627a19d9b4c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf", size = 693017, upload-time = "2024-10-21T11:26:48.866Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/b3/d650eaade4ca225f02a648321e1ab835b9d361c60d51150bac49063b83fa/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1", size = 741270, upload-time = "2024-10-21T11:26:50.213Z" },
+ { url = "https://files.pythonhosted.org/packages/87/b8/01c29b924dcbbed75cc45b30c30d565d763b9c4d540545a0eeecffb8f09c/ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01", size = 709059, upload-time = "2024-12-11T19:58:18.846Z" },
+ { url = "https://files.pythonhosted.org/packages/30/8c/ed73f047a73638257aa9377ad356bea4d96125b305c34a28766f4445cc0f/ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6", size = 98583, upload-time = "2024-10-20T10:13:09.658Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/85/e8e751d8791564dd333d5d9a4eab0a7a115f7e349595417fd50ecae3395c/ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3", size = 115190, upload-time = "2024-10-20T10:13:10.66Z" },
+]
+
+[[package]]
+name = "safetensors"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ac/cc/738f3011628920e027a11754d9cae9abec1aed00f7ae860abbf843755233/safetensors-0.6.2.tar.gz", hash = "sha256:43ff2aa0e6fa2dc3ea5524ac7ad93a9839256b8703761e76e2d0b2a3fa4f15d9", size = 197968, upload-time = "2025-08-08T13:13:58.654Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4d/b1/3f5fd73c039fc87dba3ff8b5d528bfc5a32b597fea8e7a6a4800343a17c7/safetensors-0.6.2-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:9c85ede8ec58f120bad982ec47746981e210492a6db876882aa021446af8ffba", size = 454797, upload-time = "2025-08-08T13:13:52.066Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/c9/bb114c158540ee17907ec470d01980957fdaf87b4aa07914c24eba87b9c6/safetensors-0.6.2-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:d6675cf4b39c98dbd7d940598028f3742e0375a6b4d4277e76beb0c35f4b843b", size = 432206, upload-time = "2025-08-08T13:13:50.931Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/8e/f70c34e47df3110e8e0bb268d90db8d4be8958a54ab0336c9be4fe86dac8/safetensors-0.6.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d2d2b3ce1e2509c68932ca03ab8f20570920cd9754b05063d4368ee52833ecd", size = 473261, upload-time = "2025-08-08T13:13:41.259Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/f5/be9c6a7c7ef773e1996dc214e73485286df1836dbd063e8085ee1976f9cb/safetensors-0.6.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:93de35a18f46b0f5a6a1f9e26d91b442094f2df02e9fd7acf224cfec4238821a", size = 485117, upload-time = "2025-08-08T13:13:43.506Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/55/23f2d0a2c96ed8665bf17a30ab4ce5270413f4d74b6d87dd663258b9af31/safetensors-0.6.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89a89b505f335640f9120fac65ddeb83e40f1fd081cb8ed88b505bdccec8d0a1", size = 616154, upload-time = "2025-08-08T13:13:45.096Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c6/affb0bd9ce02aa46e7acddbe087912a04d953d7a4d74b708c91b5806ef3f/safetensors-0.6.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4d0d0b937e04bdf2ae6f70cd3ad51328635fe0e6214aa1fc811f3b576b3bda", size = 520713, upload-time = "2025-08-08T13:13:46.25Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/5d/5a514d7b88e310c8b146e2404e0dc161282e78634d9358975fd56dfd14be/safetensors-0.6.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8045db2c872db8f4cbe3faa0495932d89c38c899c603f21e9b6486951a5ecb8f", size = 485835, upload-time = "2025-08-08T13:13:49.373Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/7b/4fc3b2ba62c352b2071bea9cfbad330fadda70579f617506ae1a2f129cab/safetensors-0.6.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:81e67e8bab9878bb568cffbc5f5e655adb38d2418351dc0859ccac158f753e19", size = 521503, upload-time = "2025-08-08T13:13:47.651Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/50/0057e11fe1f3cead9254315a6c106a16dd4b1a19cd247f7cc6414f6b7866/safetensors-0.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0e4d029ab0a0e0e4fdf142b194514695b1d7d3735503ba700cf36d0fc7136ce", size = 652256, upload-time = "2025-08-08T13:13:53.167Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/29/473f789e4ac242593ac1656fbece6e1ecd860bb289e635e963667807afe3/safetensors-0.6.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:fa48268185c52bfe8771e46325a1e21d317207bcabcb72e65c6e28e9ffeb29c7", size = 747281, upload-time = "2025-08-08T13:13:54.656Z" },
+ { url = "https://files.pythonhosted.org/packages/68/52/f7324aad7f2df99e05525c84d352dc217e0fa637a4f603e9f2eedfbe2c67/safetensors-0.6.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:d83c20c12c2d2f465997c51b7ecb00e407e5f94d7dec3ea0cc11d86f60d3fde5", size = 692286, upload-time = "2025-08-08T13:13:55.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/fe/cad1d9762868c7c5dc70c8620074df28ebb1a8e4c17d4c0cb031889c457e/safetensors-0.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d944cea65fad0ead848b6ec2c37cc0b197194bec228f8020054742190e9312ac", size = 655957, upload-time = "2025-08-08T13:13:57.029Z" },
+ { url = "https://files.pythonhosted.org/packages/59/a7/e2158e17bbe57d104f0abbd95dff60dda916cf277c9f9663b4bf9bad8b6e/safetensors-0.6.2-cp38-abi3-win32.whl", hash = "sha256:cab75ca7c064d3911411461151cb69380c9225798a20e712b102edda2542ddb1", size = 308926, upload-time = "2025-08-08T13:14:01.095Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c3/c0be1135726618dc1e28d181b8c442403d8dbb9e273fd791de2d4384bcdd/safetensors-0.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:c7b214870df923cbc1593c3faee16bec59ea462758699bd3fee399d00aac072c", size = 320192, upload-time = "2025-08-08T13:13:59.467Z" },
+]
+
+[[package]]
+name = "scikit-learn"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "joblib" },
+ { name = "numpy" },
+ { name = "scipy" },
+ { name = "threadpoolctl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/41/84/5f4af978fff619706b8961accac84780a6d298d82a8873446f72edb4ead0/scikit_learn-1.7.1.tar.gz", hash = "sha256:24b3f1e976a4665aa74ee0fcaac2b8fccc6ae77c8e07ab25da3ba6d3292b9802", size = 7190445, upload-time = "2025-07-18T08:01:54.5Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/16/57f176585b35ed865f51b04117947fe20f130f78940c6477b6d66279c9c2/scikit_learn-1.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3cee419b49b5bbae8796ecd690f97aa412ef1674410c23fc3257c6b8b85b8087", size = 9260431, upload-time = "2025-07-18T08:01:22.77Z" },
+ { url = "https://files.pythonhosted.org/packages/67/4e/899317092f5efcab0e9bc929e3391341cec8fb0e816c4789686770024580/scikit_learn-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2fd8b8d35817b0d9ebf0b576f7d5ffbbabdb55536b0655a8aaae629d7ffd2e1f", size = 8637191, upload-time = "2025-07-18T08:01:24.731Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/1b/998312db6d361ded1dd56b457ada371a8d8d77ca2195a7d18fd8a1736f21/scikit_learn-1.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:588410fa19a96a69763202f1d6b7b91d5d7a5d73be36e189bc6396bfb355bd87", size = 9486346, upload-time = "2025-07-18T08:01:26.713Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/09/a2aa0b4e644e5c4ede7006748f24e72863ba2ae71897fecfd832afea01b4/scikit_learn-1.7.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3142f0abe1ad1d1c31a2ae987621e41f6b578144a911ff4ac94781a583adad7", size = 9290988, upload-time = "2025-07-18T08:01:28.938Z" },
+ { url = "https://files.pythonhosted.org/packages/15/fa/c61a787e35f05f17fc10523f567677ec4eeee5f95aa4798dbbbcd9625617/scikit_learn-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ddd9092c1bd469acab337d87930067c87eac6bd544f8d5027430983f1e1ae88", size = 8735568, upload-time = "2025-07-18T08:01:30.936Z" },
+ { url = "https://files.pythonhosted.org/packages/52/f8/e0533303f318a0f37b88300d21f79b6ac067188d4824f1047a37214ab718/scikit_learn-1.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b7839687fa46d02e01035ad775982f2470be2668e13ddd151f0f55a5bf123bae", size = 9213143, upload-time = "2025-07-18T08:01:32.942Z" },
+ { url = "https://files.pythonhosted.org/packages/71/f3/f1df377d1bdfc3e3e2adc9c119c238b182293e6740df4cbeac6de2cc3e23/scikit_learn-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a10f276639195a96c86aa572ee0698ad64ee939a7b042060b98bd1930c261d10", size = 8591977, upload-time = "2025-07-18T08:01:34.967Z" },
+ { url = "https://files.pythonhosted.org/packages/99/72/c86a4cd867816350fe8dee13f30222340b9cd6b96173955819a5561810c5/scikit_learn-1.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13679981fdaebc10cc4c13c43344416a86fcbc61449cb3e6517e1df9d12c8309", size = 9436142, upload-time = "2025-07-18T08:01:37.397Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/66/277967b29bd297538dc7a6ecfb1a7dce751beabd0d7f7a2233be7a4f7832/scikit_learn-1.7.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f1262883c6a63f067a980a8cdd2d2e7f2513dddcef6a9eaada6416a7a7cbe43", size = 9282996, upload-time = "2025-07-18T08:01:39.721Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/47/9291cfa1db1dae9880420d1e07dbc7e8dd4a7cdbc42eaba22512e6bde958/scikit_learn-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:ca6d31fb10e04d50bfd2b50d66744729dbb512d4efd0223b864e2fdbfc4cee11", size = 8707418, upload-time = "2025-07-18T08:01:42.124Z" },
+ { url = "https://files.pythonhosted.org/packages/61/95/45726819beccdaa34d3362ea9b2ff9f2b5d3b8bf721bd632675870308ceb/scikit_learn-1.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:781674d096303cfe3d351ae6963ff7c958db61cde3421cd490e3a5a58f2a94ae", size = 9561466, upload-time = "2025-07-18T08:01:44.195Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/1c/6f4b3344805de783d20a51eb24d4c9ad4b11a7f75c1801e6ec6d777361fd/scikit_learn-1.7.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:10679f7f125fe7ecd5fad37dd1aa2daae7e3ad8df7f3eefa08901b8254b3e12c", size = 9040467, upload-time = "2025-07-18T08:01:46.671Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/80/abe18fe471af9f1d181904203d62697998b27d9b62124cd281d740ded2f9/scikit_learn-1.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f812729e38c8cb37f760dce71a9b83ccfb04f59b3dca7c6079dcdc60544fa9e", size = 9532052, upload-time = "2025-07-18T08:01:48.676Z" },
+ { url = "https://files.pythonhosted.org/packages/14/82/b21aa1e0c4cee7e74864d3a5a721ab8fcae5ca55033cb6263dca297ed35b/scikit_learn-1.7.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88e1a20131cf741b84b89567e1717f27a2ced228e0f29103426102bc2e3b8ef7", size = 9361575, upload-time = "2025-07-18T08:01:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/f4777fcd5627dc6695fa6b92179d0edb7a3ac1b91bcd9a1c7f64fa7ade23/scikit_learn-1.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b1bd1d919210b6a10b7554b717c9000b5485aa95a1d0f177ae0d7ee8ec750da5", size = 9277310, upload-time = "2025-07-18T08:01:52.547Z" },
+]
+
+[[package]]
+name = "scipy"
+version = "1.16.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f5/4a/b927028464795439faec8eaf0b03b011005c487bb2d07409f28bf30879c4/scipy-1.16.1.tar.gz", hash = "sha256:44c76f9e8b6e8e488a586190ab38016e4ed2f8a038af7cd3defa903c0a2238b3", size = 30580861, upload-time = "2025-07-27T16:33:30.834Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/d9/ec4864f5896232133f51382b54a08de91a9d1af7a76dfa372894026dfee2/scipy-1.16.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:81b433bbeaf35728dad619afc002db9b189e45eebe2cd676effe1fb93fef2b9c", size = 36575194, upload-time = "2025-07-27T16:27:41.321Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/6d/40e81ecfb688e9d25d34a847dca361982a6addf8e31f0957b1a54fbfa994/scipy-1.16.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:886cc81fdb4c6903a3bb0464047c25a6d1016fef77bb97949817d0c0d79f9e04", size = 28594590, upload-time = "2025-07-27T16:27:49.204Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/37/9f65178edfcc629377ce9a64fc09baebea18c80a9e57ae09a52edf84880b/scipy-1.16.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:15240c3aac087a522b4eaedb09f0ad061753c5eebf1ea430859e5bf8640d5919", size = 20866458, upload-time = "2025-07-27T16:27:54.98Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/7b/749a66766871ea4cb1d1ea10f27004db63023074c22abed51f22f09770e0/scipy-1.16.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:65f81a25805f3659b48126b5053d9e823d3215e4a63730b5e1671852a1705921", size = 23539318, upload-time = "2025-07-27T16:28:01.604Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/db/8d4afec60eb833a666434d4541a3151eedbf2494ea6d4d468cbe877f00cd/scipy-1.16.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c62eea7f607f122069b9bad3f99489ddca1a5173bef8a0c75555d7488b6f725", size = 33292899, upload-time = "2025-07-27T16:28:09.147Z" },
+ { url = "https://files.pythonhosted.org/packages/51/1e/79023ca3bbb13a015d7d2757ecca3b81293c663694c35d6541b4dca53e98/scipy-1.16.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f965bbf3235b01c776115ab18f092a95aa74c271a52577bcb0563e85738fd618", size = 35162637, upload-time = "2025-07-27T16:28:17.535Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/49/0648665f9c29fdaca4c679182eb972935b3b4f5ace41d323c32352f29816/scipy-1.16.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f006e323874ffd0b0b816d8c6a8e7f9a73d55ab3b8c3f72b752b226d0e3ac83d", size = 35490507, upload-time = "2025-07-27T16:28:25.705Z" },
+ { url = "https://files.pythonhosted.org/packages/62/8f/66cbb9d6bbb18d8c658f774904f42a92078707a7c71e5347e8bf2f52bb89/scipy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8fd15fc5085ab4cca74cb91fe0a4263b1f32e4420761ddae531ad60934c2119", size = 37923998, upload-time = "2025-07-27T16:28:34.339Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c3/61f273ae550fbf1667675701112e380881905e28448c080b23b5a181df7c/scipy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7b8013c6c066609577d910d1a2a077021727af07b6fab0ee22c2f901f22352a", size = 38508060, upload-time = "2025-07-27T16:28:43.242Z" },
+ { url = "https://files.pythonhosted.org/packages/93/0b/b5c99382b839854a71ca9482c684e3472badc62620287cbbdab499b75ce6/scipy-1.16.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5451606823a5e73dfa621a89948096c6528e2896e40b39248295d3a0138d594f", size = 36533717, upload-time = "2025-07-27T16:28:51.706Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/e5/69ab2771062c91e23e07c12e7d5033a6b9b80b0903ee709c3c36b3eb520c/scipy-1.16.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:89728678c5ca5abd610aee148c199ac1afb16e19844401ca97d43dc548a354eb", size = 28570009, upload-time = "2025-07-27T16:28:57.017Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/69/bd75dbfdd3cf524f4d753484d723594aed62cfaac510123e91a6686d520b/scipy-1.16.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e756d688cb03fd07de0fffad475649b03cb89bee696c98ce508b17c11a03f95c", size = 20841942, upload-time = "2025-07-27T16:29:01.152Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/74/add181c87663f178ba7d6144b370243a87af8476664d5435e57d599e6874/scipy-1.16.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5aa2687b9935da3ed89c5dbed5234576589dd28d0bf7cd237501ccfbdf1ad608", size = 23498507, upload-time = "2025-07-27T16:29:05.202Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/74/ece2e582a0d9550cee33e2e416cc96737dce423a994d12bbe59716f47ff1/scipy-1.16.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0851f6a1e537fe9399f35986897e395a1aa61c574b178c0d456be5b1a0f5ca1f", size = 33286040, upload-time = "2025-07-27T16:29:10.201Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/82/08e4076df538fb56caa1d489588d880ec7c52d8273a606bb54d660528f7c/scipy-1.16.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fedc2cbd1baed37474b1924c331b97bdff611d762c196fac1a9b71e67b813b1b", size = 35176096, upload-time = "2025-07-27T16:29:17.091Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/79/cd710aab8c921375711a8321c6be696e705a120e3011a643efbbcdeeabcc/scipy-1.16.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2ef500e72f9623a6735769e4b93e9dcb158d40752cdbb077f305487e3e2d1f45", size = 35490328, upload-time = "2025-07-27T16:29:22.928Z" },
+ { url = "https://files.pythonhosted.org/packages/71/73/e9cc3d35ee4526d784520d4494a3e1ca969b071fb5ae5910c036a375ceec/scipy-1.16.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:978d8311674b05a8f7ff2ea6c6bce5d8b45a0cb09d4c5793e0318f448613ea65", size = 37939921, upload-time = "2025-07-27T16:29:29.108Z" },
+ { url = "https://files.pythonhosted.org/packages/21/12/c0efd2941f01940119b5305c375ae5c0fcb7ec193f806bd8f158b73a1782/scipy-1.16.1-cp313-cp313-win_amd64.whl", hash = "sha256:81929ed0fa7a5713fcdd8b2e6f73697d3b4c4816d090dd34ff937c20fa90e8ab", size = 38479462, upload-time = "2025-07-27T16:30:24.078Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/19/c3d08b675260046a991040e1ea5d65f91f40c7df1045fffff412dcfc6765/scipy-1.16.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:bcc12db731858abda693cecdb3bdc9e6d4bd200213f49d224fe22df82687bdd6", size = 36938832, upload-time = "2025-07-27T16:29:35.057Z" },
+ { url = "https://files.pythonhosted.org/packages/81/f2/ce53db652c033a414a5b34598dba6b95f3d38153a2417c5a3883da429029/scipy-1.16.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:744d977daa4becb9fc59135e75c069f8d301a87d64f88f1e602a9ecf51e77b27", size = 29093084, upload-time = "2025-07-27T16:29:40.201Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/ae/7a10ff04a7dc15f9057d05b33737ade244e4bd195caa3f7cc04d77b9e214/scipy-1.16.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:dc54f76ac18073bcecffb98d93f03ed6b81a92ef91b5d3b135dcc81d55a724c7", size = 21365098, upload-time = "2025-07-27T16:29:44.295Z" },
+ { url = "https://files.pythonhosted.org/packages/36/ac/029ff710959932ad3c2a98721b20b405f05f752f07344622fd61a47c5197/scipy-1.16.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:367d567ee9fc1e9e2047d31f39d9d6a7a04e0710c86e701e053f237d14a9b4f6", size = 23896858, upload-time = "2025-07-27T16:29:48.784Z" },
+ { url = "https://files.pythonhosted.org/packages/71/13/d1ef77b6bd7898720e1f0b6b3743cb945f6c3cafa7718eaac8841035ab60/scipy-1.16.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4cf5785e44e19dcd32a0e4807555e1e9a9b8d475c6afff3d21c3c543a6aa84f4", size = 33438311, upload-time = "2025-07-27T16:29:54.164Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/e0/e64a6821ffbb00b4c5b05169f1c1fddb4800e9307efe3db3788995a82a2c/scipy-1.16.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3d0b80fb26d3e13a794c71d4b837e2a589d839fd574a6bbb4ee1288c213ad4a3", size = 35279542, upload-time = "2025-07-27T16:30:00.249Z" },
+ { url = "https://files.pythonhosted.org/packages/57/59/0dc3c8b43e118f1e4ee2b798dcc96ac21bb20014e5f1f7a8e85cc0653bdb/scipy-1.16.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8503517c44c18d1030d666cb70aaac1cc8913608816e06742498833b128488b7", size = 35667665, upload-time = "2025-07-27T16:30:05.916Z" },
+ { url = "https://files.pythonhosted.org/packages/45/5f/844ee26e34e2f3f9f8febb9343748e72daeaec64fe0c70e9bf1ff84ec955/scipy-1.16.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:30cc4bb81c41831ecfd6dc450baf48ffd80ef5aed0f5cf3ea775740e80f16ecc", size = 38045210, upload-time = "2025-07-27T16:30:11.655Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/d7/210f2b45290f444f1de64bc7353aa598ece9f0e90c384b4a156f9b1a5063/scipy-1.16.1-cp313-cp313t-win_amd64.whl", hash = "sha256:c24fa02f7ed23ae514460a22c57eca8f530dbfa50b1cfdbf4f37c05b5309cc39", size = 38593661, upload-time = "2025-07-27T16:30:17.825Z" },
+ { url = "https://files.pythonhosted.org/packages/81/ea/84d481a5237ed223bd3d32d6e82d7a6a96e34756492666c260cef16011d1/scipy-1.16.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:796a5a9ad36fa3a782375db8f4241ab02a091308eb079746bc0f874c9b998318", size = 36525921, upload-time = "2025-07-27T16:30:30.081Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/9f/d9edbdeff9f3a664807ae3aea383e10afaa247e8e6255e6d2aa4515e8863/scipy-1.16.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:3ea0733a2ff73fd6fdc5fecca54ee9b459f4d74f00b99aced7d9a3adb43fb1cc", size = 28564152, upload-time = "2025-07-27T16:30:35.336Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/95/8125bcb1fe04bc267d103e76516243e8d5e11229e6b306bda1024a5423d1/scipy-1.16.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:85764fb15a2ad994e708258bb4ed8290d1305c62a4e1ef07c414356a24fcfbf8", size = 20836028, upload-time = "2025-07-27T16:30:39.421Z" },
+ { url = "https://files.pythonhosted.org/packages/77/9c/bf92e215701fc70bbcd3d14d86337cf56a9b912a804b9c776a269524a9e9/scipy-1.16.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ca66d980469cb623b1759bdd6e9fd97d4e33a9fad5b33771ced24d0cb24df67e", size = 23489666, upload-time = "2025-07-27T16:30:43.663Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/00/5e941d397d9adac41b02839011594620d54d99488d1be5be755c00cde9ee/scipy-1.16.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e7cc1ffcc230f568549fc56670bcf3df1884c30bd652c5da8138199c8c76dae0", size = 33358318, upload-time = "2025-07-27T16:30:48.982Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/87/8db3aa10dde6e3e8e7eb0133f24baa011377d543f5b19c71469cf2648026/scipy-1.16.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ddfb1e8d0b540cb4ee9c53fc3dea3186f97711248fb94b4142a1b27178d8b4b", size = 35185724, upload-time = "2025-07-27T16:30:54.26Z" },
+ { url = "https://files.pythonhosted.org/packages/89/b4/6ab9ae443216807622bcff02690262d8184078ea467efee2f8c93288a3b1/scipy-1.16.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4dc0e7be79e95d8ba3435d193e0d8ce372f47f774cffd882f88ea4e1e1ddc731", size = 35554335, upload-time = "2025-07-27T16:30:59.765Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/9a/d0e9dc03c5269a1afb60661118296a32ed5d2c24298af61b676c11e05e56/scipy-1.16.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f23634f9e5adb51b2a77766dac217063e764337fbc816aa8ad9aaebcd4397fd3", size = 37960310, upload-time = "2025-07-27T16:31:06.151Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/00/c8f3130a50521a7977874817ca89e0599b1b4ee8e938bad8ae798a0e1f0d/scipy-1.16.1-cp314-cp314-win_amd64.whl", hash = "sha256:57d75524cb1c5a374958a2eae3d84e1929bb971204cc9d52213fb8589183fc19", size = 39319239, upload-time = "2025-07-27T16:31:59.942Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/f2/1ca3eda54c3a7e4c92f6acef7db7b3a057deb135540d23aa6343ef8ad333/scipy-1.16.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:d8da7c3dd67bcd93f15618938f43ed0995982eb38973023d46d4646c4283ad65", size = 36939460, upload-time = "2025-07-27T16:31:11.865Z" },
+ { url = "https://files.pythonhosted.org/packages/80/30/98c2840b293a132400c0940bb9e140171dcb8189588619048f42b2ce7b4f/scipy-1.16.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:cc1d2f2fd48ba1e0620554fe5bc44d3e8f5d4185c8c109c7fbdf5af2792cfad2", size = 29093322, upload-time = "2025-07-27T16:31:17.045Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/e6/1e6e006e850622cf2a039b62d1a6ddc4497d4851e58b68008526f04a9a00/scipy-1.16.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:21a611ced9275cb861bacadbada0b8c0623bc00b05b09eb97f23b370fc2ae56d", size = 21365329, upload-time = "2025-07-27T16:31:21.188Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/02/72a5aa5b820589dda9a25e329ca752842bfbbaf635e36bc7065a9b42216e/scipy-1.16.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8dfbb25dffc4c3dd9371d8ab456ca81beeaf6f9e1c2119f179392f0dc1ab7695", size = 23897544, upload-time = "2025-07-27T16:31:25.408Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/dc/7122d806a6f9eb8a33532982234bed91f90272e990f414f2830cfe656e0b/scipy-1.16.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0ebb7204f063fad87fc0a0e4ff4a2ff40b2a226e4ba1b7e34bf4b79bf97cd86", size = 33442112, upload-time = "2025-07-27T16:31:30.62Z" },
+ { url = "https://files.pythonhosted.org/packages/24/39/e383af23564daa1021a5b3afbe0d8d6a68ec639b943661841f44ac92de85/scipy-1.16.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f1b9e5962656f2734c2b285a8745358ecb4e4efbadd00208c80a389227ec61ff", size = 35286594, upload-time = "2025-07-27T16:31:36.112Z" },
+ { url = "https://files.pythonhosted.org/packages/95/47/1a0b0aff40c3056d955f38b0df5d178350c3d74734ec54f9c68d23910be5/scipy-1.16.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e1a106f8c023d57a2a903e771228bf5c5b27b5d692088f457acacd3b54511e4", size = 35665080, upload-time = "2025-07-27T16:31:42.025Z" },
+ { url = "https://files.pythonhosted.org/packages/64/df/ce88803e9ed6e27fe9b9abefa157cf2c80e4fa527cf17ee14be41f790ad4/scipy-1.16.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:709559a1db68a9abc3b2c8672c4badf1614f3b440b3ab326d86a5c0491eafae3", size = 38050306, upload-time = "2025-07-27T16:31:48.109Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6c/a76329897a7cae4937d403e623aa6aaea616a0bb5b36588f0b9d1c9a3739/scipy-1.16.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c0c804d60492a0aad7f5b2bb1862f4548b990049e27e828391ff2bf6f7199998", size = 39427705, upload-time = "2025-07-27T16:31:53.96Z" },
+]
+
+[[package]]
+name = "semver"
+version = "3.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" },
+]
+
+[[package]]
+name = "sentencepiece"
+version = "0.2.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/15/2e7a025fc62d764b151ae6d0f2a92f8081755ebe8d4a64099accc6f77ba6/sentencepiece-0.2.1.tar.gz", hash = "sha256:8138cec27c2f2282f4a34d9a016e3374cd40e5c6e9cb335063db66a0a3b71fad", size = 3228515, upload-time = "2025-08-12T07:00:51.718Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4a/be/32ce495aa1d0e0c323dcb1ba87096037358edee539cac5baf8755a6bd396/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57cae326c8727de58c85977b175af132a7138d84c764635d7e71bbee7e774133", size = 1943152, upload-time = "2025-08-12T06:59:40.048Z" },
+ { url = "https://files.pythonhosted.org/packages/88/7e/ff23008899a58678e98c6ff592bf4d368eee5a71af96d0df6b38a039dd4f/sentencepiece-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:56dd39a3c4d6493db3cdca7e8cc68c6b633f0d4195495cbadfcf5af8a22d05a6", size = 1325651, upload-time = "2025-08-12T06:59:41.536Z" },
+ { url = "https://files.pythonhosted.org/packages/19/84/42eb3ce4796777a1b5d3699dfd4dca85113e68b637f194a6c8d786f16a04/sentencepiece-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9381351182ff9888cc80e41c632e7e274b106f450de33d67a9e8f6043da6f76", size = 1253645, upload-time = "2025-08-12T06:59:42.903Z" },
+ { url = "https://files.pythonhosted.org/packages/89/fa/d3d5ebcba3cb9e6d3775a096251860c41a6bc53a1b9461151df83fe93255/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99f955df238021bf11f0fc37cdb54fd5e5b5f7fd30ecc3d93fb48b6815437167", size = 1316273, upload-time = "2025-08-12T06:59:44.476Z" },
+ { url = "https://files.pythonhosted.org/packages/04/88/14f2f4a2b922d8b39be45bf63d79e6cd3a9b2f248b2fcb98a69b12af12f5/sentencepiece-0.2.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdfecef430d985f1c2bcbfff3defd1d95dae876fbd0173376012d2d7d24044b", size = 1387881, upload-time = "2025-08-12T06:59:46.09Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/b8/903e5ccb77b4ef140605d5d71b4f9e0ad95d456d6184688073ed11712809/sentencepiece-0.2.1-cp312-cp312-win32.whl", hash = "sha256:a483fd29a34c3e34c39ac5556b0a90942bec253d260235729e50976f5dba1068", size = 999540, upload-time = "2025-08-12T06:59:48.023Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/81/92df5673c067148c2545b1bfe49adfd775bcc3a169a047f5a0e6575ddaca/sentencepiece-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4cdc7c36234fda305e85c32949c5211faaf8dd886096c7cea289ddc12a2d02de", size = 1054671, upload-time = "2025-08-12T06:59:49.895Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/02/c5e3bc518655d714622bec87d83db9cdba1cd0619a4a04e2109751c4f47f/sentencepiece-0.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:daeb5e9e9fcad012324807856113708614d534f596d5008638eb9b40112cd9e4", size = 1033923, upload-time = "2025-08-12T06:59:51.952Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/4a/85fbe1706d4d04a7e826b53f327c4b80f849cf1c7b7c5e31a20a97d8f28b/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dcd8161eee7b41aae57ded06272905dbd680a0a04b91edd0f64790c796b2f706", size = 1943150, upload-time = "2025-08-12T06:59:53.588Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/83/4cfb393e287509fc2155480b9d184706ef8d9fa8cbf5505d02a5792bf220/sentencepiece-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c6c8f42949f419ff8c7e9960dbadcfbc982d7b5efc2f6748210d3dd53a7de062", size = 1325651, upload-time = "2025-08-12T06:59:55.073Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/de/5a007fb53b1ab0aafc69d11a5a3dd72a289d5a3e78dcf2c3a3d9b14ffe93/sentencepiece-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:097f3394e99456e9e4efba1737c3749d7e23563dd1588ce71a3d007f25475fff", size = 1253641, upload-time = "2025-08-12T06:59:56.562Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/d2/f552be5928105588f4f4d66ee37dd4c61460d8097e62d0e2e0eec41bc61d/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7b670879c370d350557edabadbad1f6561a9e6968126e6debca4029e5547820", size = 1316271, upload-time = "2025-08-12T06:59:58.109Z" },
+ { url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", size = 1387882, upload-time = "2025-08-12T07:00:00.701Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/dd/f7774d42a881ced8e1739f393ab1e82ece39fc9abd4779e28050c2e975b5/sentencepiece-0.2.1-cp313-cp313-win32.whl", hash = "sha256:92b3816aa2339355fda2c8c4e021a5de92180b00aaccaf5e2808972e77a4b22f", size = 999541, upload-time = "2025-08-12T07:00:02.709Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/e9/932b9eae6fd7019548321eee1ab8d5e3b3d1294df9d9a0c9ac517c7b636d/sentencepiece-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:10ed3dab2044c47f7a2e7b4969b0c430420cdd45735d78c8f853191fa0e3148b", size = 1054669, upload-time = "2025-08-12T07:00:04.915Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/3a/76488a00ea7d6931689cda28726a1447d66bf1a4837943489314593d5596/sentencepiece-0.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac650534e2251083c5f75dde4ff28896ce7c8904133dc8fef42780f4d5588fcd", size = 1033922, upload-time = "2025-08-12T07:00:06.496Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/b6/08fe2ce819e02ccb0296f4843e3f195764ce9829cbda61b7513f29b95718/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8dd4b477a7b069648d19363aad0cab9bad2f4e83b2d179be668efa672500dc94", size = 1946052, upload-time = "2025-08-12T07:00:08.136Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/d9/1ea0e740591ff4c6fc2b6eb1d7510d02f3fb885093f19b2f3abd1363b402/sentencepiece-0.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0c0f672da370cc490e4c59d89e12289778310a0e71d176c541e4834759e1ae07", size = 1327408, upload-time = "2025-08-12T07:00:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/99/7e/1fb26e8a21613f6200e1ab88824d5d203714162cf2883248b517deb500b7/sentencepiece-0.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ad8493bea8432dae8d6830365352350f3b4144415a1d09c4c8cb8d30cf3b6c3c", size = 1254857, upload-time = "2025-08-12T07:00:11.021Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/85/c72fd1f3c7a6010544d6ae07f8ddb38b5e2a7e33bd4318f87266c0bbafbf/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b81a24733726e3678d2db63619acc5a8dccd074f7aa7a54ecd5ca33ca6d2d596", size = 1315722, upload-time = "2025-08-12T07:00:12.989Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/e8/661e5bd82a8aa641fd6c1020bd0e890ef73230a2b7215ddf9c8cd8e941c2/sentencepiece-0.2.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a81799d0a68d618e89063fb423c3001a034c893069135ffe51fee439ae474d6", size = 1387452, upload-time = "2025-08-12T07:00:15.088Z" },
+ { url = "https://files.pythonhosted.org/packages/99/5e/ae66c361023a470afcbc1fbb8da722c72ea678a2fcd9a18f1a12598c7501/sentencepiece-0.2.1-cp313-cp313t-win32.whl", hash = "sha256:89a3ea015517c42c0341d0d962f3e6aaf2cf10d71b1932d475c44ba48d00aa2b", size = 1002501, upload-time = "2025-08-12T07:00:16.966Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/03/d332828c4ff764e16c1b56c2c8f9a33488bbe796b53fb6b9c4205ddbf167/sentencepiece-0.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:33f068c9382dc2e7c228eedfd8163b52baa86bb92f50d0488bf2b7da7032e484", size = 1057555, upload-time = "2025-08-12T07:00:18.573Z" },
+ { url = "https://files.pythonhosted.org/packages/88/14/5aee0bf0864df9bd82bd59e7711362908e4935e3f9cdc1f57246b5d5c9b9/sentencepiece-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:b3616ad246f360e52c85781e47682d31abfb6554c779e42b65333d4b5f44ecc0", size = 1036042, upload-time = "2025-08-12T07:00:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/24/9c/89eb8b2052f720a612478baf11c8227dcf1dc28cd4ea4c0c19506b5af2a2/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5d0350b686c320068702116276cfb26c066dc7e65cfef173980b11bb4d606719", size = 1943147, upload-time = "2025-08-12T07:00:21.809Z" },
+ { url = "https://files.pythonhosted.org/packages/82/0b/a1432bc87f97c2ace36386ca23e8bd3b91fb40581b5e6148d24b24186419/sentencepiece-0.2.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c7f54a31cde6fa5cb030370566f68152a742f433f8d2be458463d06c208aef33", size = 1325624, upload-time = "2025-08-12T07:00:23.289Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/99/bbe054ebb5a5039457c590e0a4156ed073fb0fe9ce4f7523404dd5b37463/sentencepiece-0.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c83b85ab2d6576607f31df77ff86f28182be4a8de6d175d2c33ca609925f5da1", size = 1253670, upload-time = "2025-08-12T07:00:24.69Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ad/d5c7075f701bd97971d7c2ac2904f227566f51ef0838dfbdfdccb58cd212/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1855f57db07b51fb51ed6c9c452f570624d2b169b36f0f79ef71a6e6c618cd8b", size = 1316247, upload-time = "2025-08-12T07:00:26.435Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/03/35fbe5f3d9a7435eebd0b473e09584bd3cc354ce118b960445b060d33781/sentencepiece-0.2.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01e6912125cb45d3792f530a4d38f8e21bf884d6b4d4ade1b2de5cf7a8d2a52b", size = 1387894, upload-time = "2025-08-12T07:00:28.339Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/aa/956ef729aafb6c8f9c443104c9636489093bb5c61d6b90fc27aa1a865574/sentencepiece-0.2.1-cp314-cp314-win32.whl", hash = "sha256:c415c9de1447e0a74ae3fdb2e52f967cb544113a3a5ce3a194df185cbc1f962f", size = 1096698, upload-time = "2025-08-12T07:00:29.764Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/cb/fe400d8836952cc535c81a0ce47dc6875160e5fedb71d2d9ff0e9894c2a6/sentencepiece-0.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:881b2e44b14fc19feade3cbed314be37de639fc415375cefaa5bc81a4be137fd", size = 1155115, upload-time = "2025-08-12T07:00:32.865Z" },
+ { url = "https://files.pythonhosted.org/packages/32/89/047921cf70f36c7b6b6390876b2399b3633ab73b8d0cb857e5a964238941/sentencepiece-0.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:2005242a16d2dc3ac5fe18aa7667549134d37854823df4c4db244752453b78a8", size = 1133890, upload-time = "2025-08-12T07:00:34.763Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/11/5b414b9fae6255b5fb1e22e2ed3dc3a72d3a694e5703910e640ac78346bb/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:a19adcec27c524cb7069a1c741060add95f942d1cbf7ad0d104dffa0a7d28a2b", size = 1946081, upload-time = "2025-08-12T07:00:36.97Z" },
+ { url = "https://files.pythonhosted.org/packages/77/eb/7a5682bb25824db8545f8e5662e7f3e32d72a508fdce086029d89695106b/sentencepiece-0.2.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e37e4b4c4a11662b5db521def4e44d4d30ae69a1743241412a93ae40fdcab4bb", size = 1327406, upload-time = "2025-08-12T07:00:38.669Z" },
+ { url = "https://files.pythonhosted.org/packages/03/b0/811dae8fb9f2784e138785d481469788f2e0d0c109c5737372454415f55f/sentencepiece-0.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:477c81505db072b3ab627e7eab972ea1025331bd3a92bacbf798df2b75ea86ec", size = 1254846, upload-time = "2025-08-12T07:00:40.611Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/23/195b2e7ec85ebb6a547969f60b723c7aca5a75800ece6cc3f41da872d14e/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:010f025a544ef770bb395091d57cb94deb9652d8972e0d09f71d85d5a0816c8c", size = 1315721, upload-time = "2025-08-12T07:00:42.914Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/aa/553dbe4178b5f23eb28e59393dddd64186178b56b81d9b8d5c3ff1c28395/sentencepiece-0.2.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:733e59ff1794d26db706cd41fc2d7ca5f6c64a820709cb801dc0ea31780d64ab", size = 1387458, upload-time = "2025-08-12T07:00:44.56Z" },
+ { url = "https://files.pythonhosted.org/packages/66/7c/08ff0012507297a4dd74a5420fdc0eb9e3e80f4e88cab1538d7f28db303d/sentencepiece-0.2.1-cp314-cp314t-win32.whl", hash = "sha256:d3233770f78e637dc8b1fda2cd7c3b99ec77e7505041934188a4e7fe751de3b0", size = 1099765, upload-time = "2025-08-12T07:00:46.058Z" },
+ { url = "https://files.pythonhosted.org/packages/91/d5/2a69e1ce15881beb9ddfc7e3f998322f5cedcd5e4d244cb74dade9441663/sentencepiece-0.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e4366c97b68218fd30ea72d70c525e6e78a6c0a88650f57ac4c43c63b234a9d", size = 1157807, upload-time = "2025-08-12T07:00:47.673Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/16/54f611fcfc2d1c46cbe3ec4169780b2cfa7cf63708ef2b71611136db7513/sentencepiece-0.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:105e36e75cbac1292642045458e8da677b2342dcd33df503e640f0b457cb6751", size = 1136264, upload-time = "2025-08-12T07:00:49.485Z" },
+]
+
+[[package]]
+name = "sentry-sdk"
+version = "2.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/af/9a/0b2eafc31d5c7551b6bef54ca10d29adea471e0bd16bfe985a9dc4b6633e/sentry_sdk-2.37.0.tar.gz", hash = "sha256:2c661a482dd5accf3df58464f31733545745bb4d5cf8f5e46e0e1c4eed88479f", size = 346203, upload-time = "2025-09-05T11:41:43.848Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/d5/f9f4a2bf5db2ca8f692c46f3821fee1f302f1b76a0e2914aee5390fca565/sentry_sdk-2.37.0-py2.py3-none-any.whl", hash = "sha256:89c1ed205d5c25926558b64a9bed8a5b4fb295b007cecc32c0ec4bf7694da2e1", size = 368304, upload-time = "2025-09-05T11:41:41.286Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "80.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
+]
+
+[[package]]
+name = "shellingham"
+version = "1.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
+]
+
+[[package]]
+name = "silero-vad"
+version = "5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "onnxruntime" },
+ { name = "torch" },
+ { name = "torchaudio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7c/5d/b912e45d21b8b61859a552554893222d2cdebfd0f9afa7e8ba69c7a3441a/silero_vad-5.1.tar.gz", hash = "sha256:c644275ba5df06cee596cc050ba0bd1e0f5237d1abfa44d58dd4618f6e77434d", size = 3996829, upload-time = "2024-07-09T13:19:24.181Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0e/be/0fdbc72030b93d6f55107490d5d2185ddf0dbabdc921f589649d3e92ccd5/silero_vad-5.1-py3-none-any.whl", hash = "sha256:ecb50b484f538f7a962ce5cd3c07120d9db7b9d5a0c5861ccafe459856f22c8f", size = 3939986, upload-time = "2024-07-09T13:19:21.383Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
+[[package]]
+name = "soundfile"
+version = "0.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" },
+ { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" },
+]
+
+[[package]]
+name = "soxr"
+version = "0.5.0.post1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/02/c0/4429bf9b3be10e749149e286aa5c53775399ec62891c6b970456c6dca325/soxr-0.5.0.post1.tar.gz", hash = "sha256:7092b9f3e8a416044e1fa138c8172520757179763b85dc53aa9504f4813cff73", size = 170853, upload-time = "2024-08-31T03:43:33.058Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/e3/d422d279e51e6932e7b64f1170a4f61a7ee768e0f84c9233a5b62cd2c832/soxr-0.5.0.post1-cp312-abi3-macosx_10_14_x86_64.whl", hash = "sha256:fef509466c9c25f65eae0ce1e4b9ac9705d22c6038c914160ddaf459589c6e31", size = 199993, upload-time = "2024-08-31T03:43:17.24Z" },
+ { url = "https://files.pythonhosted.org/packages/20/f1/88adaca3c52e03bcb66b63d295df2e2d35bf355d19598c6ce84b20be7fca/soxr-0.5.0.post1-cp312-abi3-macosx_11_0_arm64.whl", hash = "sha256:4704ba6b13a3f1e41d12acf192878384c1c31f71ce606829c64abdf64a8d7d32", size = 156373, upload-time = "2024-08-31T03:43:18.633Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/38/bad15a9e615215c8219652ca554b601663ac3b7ac82a284aca53ec2ff48c/soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd052a66471a7335b22a6208601a9d0df7b46b8d087dce4ff6e13eed6a33a2a1", size = 216564, upload-time = "2024-08-31T03:43:20.789Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/1a/569ea0420a0c4801c2c8dd40d8d544989522f6014d51def689125f3f2935/soxr-0.5.0.post1-cp312-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3f16810dd649ab1f433991d2a9661e9e6a116c2b4101039b53b3c3e90a094fc", size = 248455, upload-time = "2024-08-31T03:43:22.165Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/10/440f1ba3d4955e0dc740bbe4ce8968c254a3d644d013eb75eea729becdb8/soxr-0.5.0.post1-cp312-abi3-win_amd64.whl", hash = "sha256:b1be9fee90afb38546bdbd7bde714d1d9a8c5a45137f97478a83b65e7f3146f6", size = 164937, upload-time = "2024-08-31T03:43:23.671Z" },
+]
+
+[[package]]
+name = "speechbrain"
+version = "1.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "hyperpyyaml" },
+ { name = "joblib" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "scipy" },
+ { name = "sentencepiece" },
+ { name = "torch" },
+ { name = "torchaudio" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ab/10/87e666544a4e0cec7cbdc09f26948994831ae0f8bbc58de3bf53b68285ff/speechbrain-1.0.3.tar.gz", hash = "sha256:fcab3c6e90012cecb1eed40ea235733b550137e73da6bfa2340ba191ec714052", size = 747735, upload-time = "2025-04-07T17:17:06.749Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/58/13/e61f1085aebee17d5fc2df19fcc5177c10379be52578afbecdd615a831c9/speechbrain-1.0.3-py3-none-any.whl", hash = "sha256:9859d4c1b1fb3af3b85523c0c89f52e45a04f305622ed55f31aa32dd2fba19e9", size = 864091, upload-time = "2025-04-07T17:17:04.706Z" },
+]
+
+[[package]]
+name = "sqlalchemy"
+version = "2.0.43"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload-time = "2025-08-11T15:51:13.019Z" },
+ { url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload-time = "2025-08-11T15:51:14.319Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload-time = "2025-08-11T15:52:35.088Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload-time = "2025-08-11T15:56:34.153Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload-time = "2025-08-11T15:52:36.933Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload-time = "2025-08-11T15:56:35.553Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload-time = "2025-08-11T15:55:00.645Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload-time = "2025-08-11T15:55:02.965Z" },
+ { url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" },
+ { url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "0.47.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
+]
+
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+]
+
+[[package]]
+name = "tabulate"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" },
+]
+
+[[package]]
+name = "tensorboardx"
+version = "2.6.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2b/c5/d4cc6e293fb837aaf9f76dd7745476aeba8ef7ef5146c3b3f9ee375fe7a5/tensorboardx-2.6.4.tar.gz", hash = "sha256:b163ccb7798b31100b9f5fa4d6bc22dad362d7065c2f24b51e50731adde86828", size = 4769801, upload-time = "2025-06-10T22:37:07.419Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/1d/b5d63f1a6b824282b57f7b581810d20b7a28ca951f2d5b59f1eb0782c12b/tensorboardx-2.6.4-py3-none-any.whl", hash = "sha256:5970cf3a1f0a6a6e8b180ccf46f3fe832b8a25a70b86e5a237048a7c0beb18e2", size = 87201, upload-time = "2025-06-10T22:37:05.44Z" },
+]
+
+[[package]]
+name = "threadpoolctl"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
+]
+
+[[package]]
+name = "tokenizers"
+version = "0.22.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/b4/c1ce3699e81977da2ace8b16d2badfd42b060e7d33d75c4ccdbf9dc920fa/tokenizers-0.22.0.tar.gz", hash = "sha256:2e33b98525be8453f355927f3cab312c36cd3e44f4d7e9e97da2fa94d0a49dcb", size = 362771, upload-time = "2025-08-29T10:25:33.914Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6d/b1/18c13648edabbe66baa85fe266a478a7931ddc0cd1ba618802eb7b8d9865/tokenizers-0.22.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:eaa9620122a3fb99b943f864af95ed14c8dfc0f47afa3b404ac8c16b3f2bb484", size = 3081954, upload-time = "2025-08-29T10:25:24.993Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/02/c3c454b641bd7c4f79e4464accfae9e7dfc913a777d2e561e168ae060362/tokenizers-0.22.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:71784b9ab5bf0ff3075bceeb198149d2c5e068549c0d18fe32d06ba0deb63f79", size = 2945644, upload-time = "2025-08-29T10:25:23.405Z" },
+ { url = "https://files.pythonhosted.org/packages/55/02/d10185ba2fd8c2d111e124c9d92de398aee0264b35ce433f79fb8472f5d0/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec5b71f668a8076802b0241a42387d48289f25435b86b769ae1837cad4172a17", size = 3254764, upload-time = "2025-08-29T10:25:12.445Z" },
+ { url = "https://files.pythonhosted.org/packages/13/89/17514bd7ef4bf5bfff58e2b131cec0f8d5cea2b1c8ffe1050a2c8de88dbb/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ea8562fa7498850d02a16178105b58803ea825b50dc9094d60549a7ed63654bb", size = 3161654, upload-time = "2025-08-29T10:25:15.493Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/d8/bac9f3a7ef6dcceec206e3857c3b61bb16c6b702ed7ae49585f5bd85c0ef/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4136e1558a9ef2e2f1de1555dcd573e1cbc4a320c1a06c4107a3d46dc8ac6e4b", size = 3511484, upload-time = "2025-08-29T10:25:20.477Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/27/9c9800eb6763683010a4851db4d1802d8cab9cec114c17056eccb4d4a6e0/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf5954de3962a5fd9781dc12048d24a1a6f1f5df038c6e95db328cd22964206", size = 3712829, upload-time = "2025-08-29T10:25:17.154Z" },
+ { url = "https://files.pythonhosted.org/packages/10/e3/b1726dbc1f03f757260fa21752e1921445b5bc350389a8314dd3338836db/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8337ca75d0731fc4860e6204cc24bb36a67d9736142aa06ed320943b50b1e7ed", size = 3408934, upload-time = "2025-08-29T10:25:18.76Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/61/aeab3402c26874b74bb67a7f2c4b569dde29b51032c5384db592e7b216f4/tokenizers-0.22.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a89264e26f63c449d8cded9061adea7b5de53ba2346fc7e87311f7e4117c1cc8", size = 3345585, upload-time = "2025-08-29T10:25:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/d3/498b4a8a8764cce0900af1add0f176ff24f475d4413d55b760b8cdf00893/tokenizers-0.22.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:790bad50a1b59d4c21592f9c3cf5e5cf9c3c7ce7e1a23a739f13e01fb1be377a", size = 9322986, upload-time = "2025-08-29T10:25:26.607Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/62/92378eb1c2c565837ca3cb5f9569860d132ab9d195d7950c1ea2681dffd0/tokenizers-0.22.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:76cf6757c73a10ef10bf06fa937c0ec7393d90432f543f49adc8cab3fb6f26cb", size = 9276630, upload-time = "2025-08-29T10:25:28.349Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/f0/342d80457aa1cda7654327460f69db0d69405af1e4c453f4dc6ca7c4a76e/tokenizers-0.22.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1626cb186e143720c62c6c6b5371e62bbc10af60481388c0da89bc903f37ea0c", size = 9547175, upload-time = "2025-08-29T10:25:29.989Z" },
+ { url = "https://files.pythonhosted.org/packages/14/84/8aa9b4adfc4fbd09381e20a5bc6aa27040c9c09caa89988c01544e008d18/tokenizers-0.22.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:da589a61cbfea18ae267723d6b029b84598dc8ca78db9951d8f5beff72d8507c", size = 9692735, upload-time = "2025-08-29T10:25:32.089Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/24/83ee2b1dc76bfe05c3142e7d0ccdfe69f0ad2f1ebf6c726cea7f0874c0d0/tokenizers-0.22.0-cp39-abi3-win32.whl", hash = "sha256:dbf9d6851bddae3e046fedfb166f47743c1c7bd11c640f0691dd35ef0bcad3be", size = 2471915, upload-time = "2025-08-29T10:25:36.411Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/9b/0e0bf82214ee20231845b127aa4a8015936ad5a46779f30865d10e404167/tokenizers-0.22.0-cp39-abi3-win_amd64.whl", hash = "sha256:c78174859eeaee96021f248a56c801e36bfb6bd5b067f2e95aa82445ca324f00", size = 2680494, upload-time = "2025-08-29T10:25:35.14Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "jinja2" },
+ { name = "networkx" },
+ { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "setuptools" },
+ { name = "sympy" },
+ { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
+ { name = "typing-extensions" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/0c/2fd4df0d83a495bb5e54dca4474c4ec5f9c62db185421563deeb5dabf609/torch-2.8.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e2fab4153768d433f8ed9279c8133a114a034a61e77a3a104dcdf54388838705", size = 101906089, upload-time = "2025-08-06T14:53:52.631Z" },
+ { url = "https://files.pythonhosted.org/packages/99/a8/6acf48d48838fb8fe480597d98a0668c2beb02ee4755cc136de92a0a956f/torch-2.8.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2aca0939fb7e4d842561febbd4ffda67a8e958ff725c1c27e244e85e982173c", size = 887913624, upload-time = "2025-08-06T14:56:44.33Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8a/5c87f08e3abd825c7dfecef5a0f1d9aa5df5dd0e3fd1fa2f490a8e512402/torch-2.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:2f4ac52f0130275d7517b03a33d2493bab3693c83dcfadf4f81688ea82147d2e", size = 241326087, upload-time = "2025-08-06T14:53:46.503Z" },
+ { url = "https://files.pythonhosted.org/packages/be/66/5c9a321b325aaecb92d4d1855421e3a055abd77903b7dab6575ca07796db/torch-2.8.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:619c2869db3ada2c0105487ba21b5008defcc472d23f8b80ed91ac4a380283b0", size = 73630478, upload-time = "2025-08-06T14:53:57.144Z" },
+ { url = "https://files.pythonhosted.org/packages/10/4e/469ced5a0603245d6a19a556e9053300033f9c5baccf43a3d25ba73e189e/torch-2.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2b2f96814e0345f5a5aed9bf9734efa913678ed19caf6dc2cddb7930672d6128", size = 101936856, upload-time = "2025-08-06T14:54:01.526Z" },
+ { url = "https://files.pythonhosted.org/packages/16/82/3948e54c01b2109238357c6f86242e6ecbf0c63a1af46906772902f82057/torch-2.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:65616ca8ec6f43245e1f5f296603e33923f4c30f93d65e103d9e50c25b35150b", size = 887922844, upload-time = "2025-08-06T14:55:50.78Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/54/941ea0a860f2717d86a811adf0c2cd01b3983bdd460d0803053c4e0b8649/torch-2.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:659df54119ae03e83a800addc125856effda88b016dfc54d9f65215c3975be16", size = 241330968, upload-time = "2025-08-06T14:54:45.293Z" },
+ { url = "https://files.pythonhosted.org/packages/de/69/8b7b13bba430f5e21d77708b616f767683629fc4f8037564a177d20f90ed/torch-2.8.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:1a62a1ec4b0498930e2543535cf70b1bef8c777713de7ceb84cd79115f553767", size = 73915128, upload-time = "2025-08-06T14:54:34.769Z" },
+ { url = "https://files.pythonhosted.org/packages/15/0e/8a800e093b7f7430dbaefa80075aee9158ec22e4c4fc3c1a66e4fb96cb4f/torch-2.8.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:83c13411a26fac3d101fe8035a6b0476ae606deb8688e904e796a3534c197def", size = 102020139, upload-time = "2025-08-06T14:54:39.047Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/15/5e488ca0bc6162c86a33b58642bc577c84ded17c7b72d97e49b5833e2d73/torch-2.8.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:8f0a9d617a66509ded240add3754e462430a6c1fc5589f86c17b433dd808f97a", size = 887990692, upload-time = "2025-08-06T14:56:18.286Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/a8/6a04e4b54472fc5dba7ca2341ab219e529f3c07b6941059fbf18dccac31f/torch-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a7242b86f42be98ac674b88a4988643b9bc6145437ec8f048fea23f72feb5eca", size = 241603453, upload-time = "2025-08-06T14:55:22.945Z" },
+ { url = "https://files.pythonhosted.org/packages/04/6e/650bb7f28f771af0cb791b02348db8b7f5f64f40f6829ee82aa6ce99aabe/torch-2.8.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:7b677e17f5a3e69fdef7eb3b9da72622f8d322692930297e4ccb52fefc6c8211", size = 73632395, upload-time = "2025-08-06T14:55:28.645Z" },
+]
+
+[[package]]
+name = "torch-audiomentations"
+version = "0.12.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "julius" },
+ { name = "torch" },
+ { name = "torch-pitch-shift" },
+ { name = "torchaudio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/31/8d/2f8fd7e34c75f5ee8de4310c3bd3f22270acd44d1f809e2fe7c12fbf35f8/torch_audiomentations-0.12.0.tar.gz", hash = "sha256:b02d4c5eb86376986a53eb405cca5e34f370ea9284411237508e720c529f7888", size = 52094, upload-time = "2025-01-15T09:07:01.071Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/21/9d/1ee04f49c15d2d632f6f7102061d7c07652858e6d91b58a091531034e84f/torch_audiomentations-0.12.0-py3-none-any.whl", hash = "sha256:1b80b91d2016ccf83979622cac8f702072a79b7dcc4c2bee40f00b26433a786b", size = 48506, upload-time = "2025-01-15T09:06:59.687Z" },
+]
+
+[[package]]
+name = "torch-pitch-shift"
+version = "1.2.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "packaging" },
+ { name = "primepy" },
+ { name = "torch" },
+ { name = "torchaudio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/a6/722a832bca75d5079f6731e005b3d0c2eec7c6c6863d030620952d143d57/torch_pitch_shift-1.2.5.tar.gz", hash = "sha256:6e1c7531f08d0f407a4c55e5ff8385a41355c5c5d27ab7fa08632e51defbd0ed", size = 4725, upload-time = "2024-09-25T19:10:12.922Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/27/4c/96ac2a09efb56cc3c41fb3ce9b6f4d8c0604499f7481d4a13a7b03e21382/torch_pitch_shift-1.2.5-py3-none-any.whl", hash = "sha256:6f8500cbc13f1c98b11cde1805ce5084f82cdd195c285f34287541f168a7c6a7", size = 5005, upload-time = "2024-09-25T19:10:11.521Z" },
+]
+
+[[package]]
+name = "torchaudio"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "torch" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ac/cc/c2e2a3eb6ee956f73c68541e439916f8146170ea9cc61e72adea5c995312/torchaudio-2.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ddef94bf181e6447cbb05f38beaca8f6c5bb8d2b9ddced1aa3452025b9fc70d3", size = 1856736, upload-time = "2025-08-06T14:58:36.3Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/0d/24dad878784f1edd62862f27173781669f0c71eb46368636787d1e364188/torchaudio-2.8.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:862e2e40bf09d865e5df080a84c1a39bbcef40e43140f4b1737eb3a389d3b38f", size = 1692930, upload-time = "2025-08-06T14:58:41.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/a6/84d80f34472503e9eb82245d7df501c59602d75d7360e717fb9b84f91c5e/torchaudio-2.8.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:93a8583f280fe83ba021aa713319381ea71362cc87b67ee38e97a43cb2254aee", size = 4014607, upload-time = "2025-08-06T14:58:47.234Z" },
+ { url = "https://files.pythonhosted.org/packages/43/ab/96ad33afa320738a7cfb4b51ba97e2f3cfb1e04ae3115d5057655103ba4f/torchaudio-2.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:4b82cacd1b8ccd543b1149d8cab257a40dfda8119023d2e3a96c66349c84bffb", size = 2499890, upload-time = "2025-08-06T14:58:55.066Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/ea/2a68259c4dbb5fe44ebfdcfa40b115010d8c677221a7ef0f5577f3c4f5f1/torchaudio-2.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f851d32e94ca05e470f0c60e25726ec1e0eb71cb2ca5a0206b7fd03272ccc3c8", size = 1857045, upload-time = "2025-08-06T14:58:51.984Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/a3/1c79a8ef29fe403b83bdfc033db852bc2a888b80c406325e5c6fb37a7f2d/torchaudio-2.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:09535a9b727c0793cd07c1ace99f3f353626281bcc3e30c2f2314e3ebc9d3f96", size = 1692755, upload-time = "2025-08-06T14:58:50.868Z" },
+ { url = "https://files.pythonhosted.org/packages/49/df/61941198e9ac6bcebfdd57e1836e4f3c23409308e3d8d7458f0198a6a366/torchaudio-2.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:d2a85b124494736241884372fe1c6dd8c15e9bc1931bd325838c5c00238c7378", size = 4013897, upload-time = "2025-08-06T14:59:01.66Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/ab/7175d35a4bbc4a465a9f1388571842f16eb6dec5069d7ea9c8c2d7b5b401/torchaudio-2.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:c1b5139c840367a7855a062a06688a416619f6fd2ca46d9b9299b49a7d133dfd", size = 2500085, upload-time = "2025-08-06T14:58:44.95Z" },
+ { url = "https://files.pythonhosted.org/packages/34/1a/69b9f8349d9d57953d5e7e445075cbf74000173fb5f5d5d9e9d59415fc63/torchaudio-2.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:68df9c9068984edff8065c2b6656725e6114fe89281b0cf122c7505305fc98a4", size = 1935600, upload-time = "2025-08-06T14:58:46.051Z" },
+ { url = "https://files.pythonhosted.org/packages/71/76/40fec21b65bccfdc5c8cdb9d511033ab07a7ad4b05f0a5b07f85c68279fc/torchaudio-2.8.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:1951f10ed092f2dda57634f6a3950ef21c9d9352551aa84a9fccd51bbda18095", size = 1704199, upload-time = "2025-08-06T14:58:43.594Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/53/95c3363413c2f2009f805144160b093a385f641224465fbcd717449c71fb/torchaudio-2.8.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4f7d97494698d98854129349b12061e8c3398d33bd84c929fa9aed5fd1389f73", size = 4020596, upload-time = "2025-08-06T14:59:03.031Z" },
+ { url = "https://files.pythonhosted.org/packages/52/27/7fc2d7435af044ffbe0b9b8e98d99eac096d43f128a5cde23c04825d5dcf/torchaudio-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d4a715d09ac28c920d031ee1e60ecbc91e8a5079ad8c61c0277e658436c821a6", size = 2549553, upload-time = "2025-08-06T14:59:00.019Z" },
+]
+
+[[package]]
+name = "torchmetrics"
+version = "1.8.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lightning-utilities" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "torch" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679, upload-time = "2025-09-03T14:00:54.077Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161, upload-time = "2025-09-03T14:00:51.921Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" },
+]
+
+[[package]]
+name = "transformers"
+version = "4.56.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "huggingface-hub" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "regex" },
+ { name = "requests" },
+ { name = "safetensors" },
+ { name = "tokenizers" },
+ { name = "tqdm" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/89/21/dc88ef3da1e49af07ed69386a11047a31dcf1aaf4ded3bc4b173fbf94116/transformers-4.56.1.tar.gz", hash = "sha256:0d88b1089a563996fc5f2c34502f10516cad3ea1aa89f179f522b54c8311fe74", size = 9855473, upload-time = "2025-09-04T20:47:13.14Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/7c/283c3dd35e00e22a7803a0b2a65251347b745474a82399be058bde1c9f15/transformers-4.56.1-py3-none-any.whl", hash = "sha256:1697af6addfb6ddbce9618b763f4b52d5a756f6da4899ffd1b4febf58b779248", size = 11608197, upload-time = "2025-09-04T20:47:04.895Z" },
+]
+
+[[package]]
+name = "triton"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "setuptools" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d0/66/b1eb52839f563623d185f0927eb3530ee4d5ffe9d377cdaf5346b306689e/triton-3.4.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31c1d84a5c0ec2c0f8e8a072d7fd150cab84a9c239eaddc6706c081bfae4eb04", size = 155560068, upload-time = "2025-07-30T19:58:37.081Z" },
+ { url = "https://files.pythonhosted.org/packages/30/7b/0a685684ed5322d2af0bddefed7906674f67974aa88b0fae6e82e3b766f6/triton-3.4.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00be2964616f4c619193cb0d1b29a99bd4b001d7dc333816073f92cf2a8ccdeb", size = 155569223, upload-time = "2025-07-30T19:58:44.017Z" },
+ { url = "https://files.pythonhosted.org/packages/20/63/8cb444ad5cdb25d999b7d647abac25af0ee37d292afc009940c05b82dda0/triton-3.4.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7936b18a3499ed62059414d7df563e6c163c5e16c3773678a3ee3d417865035d", size = 155659780, upload-time = "2025-07-30T19:58:51.171Z" },
+]
+
+[[package]]
+name = "typer"
+version = "0.17.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "rich" },
+ { name = "shellingham" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/82/f4bfed3bc18c6ebd6f828320811bbe4098f92a31adf4040bee59c4ae02ea/typer-0.17.3.tar.gz", hash = "sha256:0c600503d472bcf98d29914d4dcd67f80c24cc245395e2e00ba3603c9332e8ba", size = 103517, upload-time = "2025-08-30T12:35:24.05Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ca/e8/b3d537470e8404659a6335e7af868e90657efb73916ef31ddf3d8b9cb237/typer-0.17.3-py3-none-any.whl", hash = "sha256:643919a79182ab7ac7581056d93c6a2b865b026adf2872c4d02c72758e6f095b", size = 46494, upload-time = "2025-08-30T12:35:22.391Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.35.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
+]
+
+[package.optional-dependencies]
+standard = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "httptools" },
+ { name = "python-dotenv" },
+ { name = "pyyaml" },
+ { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
+ { name = "watchfiles" },
+ { name = "websockets" },
+]
+
+[[package]]
+name = "uvloop"
+version = "0.21.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" },
+ { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" },
+ { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" },
+ { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" },
+ { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" },
+ { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" },
+ { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" },
+]
+
+[[package]]
+name = "watchfiles"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" },
+ { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" },
+ { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" },
+ { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" },
+ { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" },
+ { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" },
+ { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" },
+ { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" },
+ { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" },
+ { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" },
+ { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" },
+ { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" },
+ { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" },
+ { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" },
+ { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" },
+ { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" },
+ { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" },
+ { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" },
+ { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" },
+ { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" },
+ { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" },
+ { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" },
+ { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" },
+ { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" },
+ { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" },
+]
+
+[[package]]
+name = "websockets"
+version = "15.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
+ { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
+ { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
+ { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
+ { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
+ { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
+ { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.20.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3c/fb/efaa23fa4e45537b827620f04cf8f3cd658b76642205162e072703a5b963/yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac", size = 186428, upload-time = "2025-06-10T00:46:09.923Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/9a/cb7fad7d73c69f296eda6815e4a2c7ed53fc70c2f136479a91c8e5fbdb6d/yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9", size = 133667, upload-time = "2025-06-10T00:43:44.369Z" },
+ { url = "https://files.pythonhosted.org/packages/67/38/688577a1cb1e656e3971fb66a3492501c5a5df56d99722e57c98249e5b8a/yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a", size = 91025, upload-time = "2025-06-10T00:43:46.295Z" },
+ { url = "https://files.pythonhosted.org/packages/50/ec/72991ae51febeb11a42813fc259f0d4c8e0507f2b74b5514618d8b640365/yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2", size = 89709, upload-time = "2025-06-10T00:43:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/99/da/4d798025490e89426e9f976702e5f9482005c548c579bdae792a4c37769e/yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee", size = 352287, upload-time = "2025-06-10T00:43:49.924Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/26/54a15c6a567aac1c61b18aa0f4b8aa2e285a52d547d1be8bf48abe2b3991/yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819", size = 345429, upload-time = "2025-06-10T00:43:51.7Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/95/9dcf2386cb875b234353b93ec43e40219e14900e046bf6ac118f94b1e353/yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16", size = 365429, upload-time = "2025-06-10T00:43:53.494Z" },
+ { url = "https://files.pythonhosted.org/packages/91/b2/33a8750f6a4bc224242a635f5f2cff6d6ad5ba651f6edcccf721992c21a0/yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6", size = 363862, upload-time = "2025-06-10T00:43:55.766Z" },
+ { url = "https://files.pythonhosted.org/packages/98/28/3ab7acc5b51f4434b181b0cee8f1f4b77a65919700a355fb3617f9488874/yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd", size = 355616, upload-time = "2025-06-10T00:43:58.056Z" },
+ { url = "https://files.pythonhosted.org/packages/36/a3/f666894aa947a371724ec7cd2e5daa78ee8a777b21509b4252dd7bd15e29/yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a", size = 339954, upload-time = "2025-06-10T00:43:59.773Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/81/5f466427e09773c04219d3450d7a1256138a010b6c9f0af2d48565e9ad13/yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38", size = 365575, upload-time = "2025-06-10T00:44:02.051Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/e3/e4b0ad8403e97e6c9972dd587388940a032f030ebec196ab81a3b8e94d31/yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef", size = 365061, upload-time = "2025-06-10T00:44:04.196Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/99/b8a142e79eb86c926f9f06452eb13ecb1bb5713bd01dc0038faf5452e544/yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f", size = 364142, upload-time = "2025-06-10T00:44:06.527Z" },
+ { url = "https://files.pythonhosted.org/packages/34/f2/08ed34a4a506d82a1a3e5bab99ccd930a040f9b6449e9fd050320e45845c/yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8", size = 381894, upload-time = "2025-06-10T00:44:08.379Z" },
+ { url = "https://files.pythonhosted.org/packages/92/f8/9a3fbf0968eac704f681726eff595dce9b49c8a25cd92bf83df209668285/yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a", size = 383378, upload-time = "2025-06-10T00:44:10.51Z" },
+ { url = "https://files.pythonhosted.org/packages/af/85/9363f77bdfa1e4d690957cd39d192c4cacd1c58965df0470a4905253b54f/yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004", size = 374069, upload-time = "2025-06-10T00:44:12.834Z" },
+ { url = "https://files.pythonhosted.org/packages/35/99/9918c8739ba271dcd935400cff8b32e3cd319eaf02fcd023d5dcd487a7c8/yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5", size = 81249, upload-time = "2025-06-10T00:44:14.731Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/83/5d9092950565481b413b31a23e75dd3418ff0a277d6e0abf3729d4d1ce25/yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698", size = 86710, upload-time = "2025-06-10T00:44:16.716Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/e1/2411b6d7f769a07687acee88a062af5833cf1966b7266f3d8dfb3d3dc7d3/yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a", size = 131811, upload-time = "2025-06-10T00:44:18.933Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/27/584394e1cb76fb771371770eccad35de400e7b434ce3142c2dd27392c968/yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3", size = 90078, upload-time = "2025-06-10T00:44:20.635Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/9a/3246ae92d4049099f52d9b0fe3486e3b500e29b7ea872d0f152966fc209d/yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7", size = 88748, upload-time = "2025-06-10T00:44:22.34Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/25/35afe384e31115a1a801fbcf84012d7a066d89035befae7c5d4284df1e03/yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691", size = 349595, upload-time = "2025-06-10T00:44:24.314Z" },
+ { url = "https://files.pythonhosted.org/packages/28/2d/8aca6cb2cabc8f12efcb82749b9cefecbccfc7b0384e56cd71058ccee433/yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31", size = 342616, upload-time = "2025-06-10T00:44:26.167Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/1312633d16b31acf0098d30440ca855e3492d66623dafb8e25b03d00c3da/yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28", size = 361324, upload-time = "2025-06-10T00:44:27.915Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/a0/688cc99463f12f7669eec7c8acc71ef56a1521b99eab7cd3abb75af887b0/yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653", size = 359676, upload-time = "2025-06-10T00:44:30.041Z" },
+ { url = "https://files.pythonhosted.org/packages/af/44/46407d7f7a56e9a85a4c207724c9f2c545c060380718eea9088f222ba697/yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5", size = 352614, upload-time = "2025-06-10T00:44:32.171Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/91/31163295e82b8d5485d31d9cf7754d973d41915cadce070491778d9c9825/yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02", size = 336766, upload-time = "2025-06-10T00:44:34.494Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/8e/c41a5bc482121f51c083c4c2bcd16b9e01e1cf8729e380273a952513a21f/yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53", size = 364615, upload-time = "2025-06-10T00:44:36.856Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/5b/61a3b054238d33d70ea06ebba7e58597891b71c699e247df35cc984ab393/yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc", size = 360982, upload-time = "2025-06-10T00:44:39.141Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a3/6a72fb83f8d478cb201d14927bc8040af901811a88e0ff2da7842dd0ed19/yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04", size = 369792, upload-time = "2025-06-10T00:44:40.934Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/af/4cc3c36dfc7c077f8dedb561eb21f69e1e9f2456b91b593882b0b18c19dc/yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4", size = 382049, upload-time = "2025-06-10T00:44:42.854Z" },
+ { url = "https://files.pythonhosted.org/packages/19/3a/e54e2c4752160115183a66dc9ee75a153f81f3ab2ba4bf79c3c53b33de34/yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b", size = 384774, upload-time = "2025-06-10T00:44:45.275Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/20/200ae86dabfca89060ec6447649f219b4cbd94531e425e50d57e5f5ac330/yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1", size = 374252, upload-time = "2025-06-10T00:44:47.31Z" },
+ { url = "https://files.pythonhosted.org/packages/83/75/11ee332f2f516b3d094e89448da73d557687f7d137d5a0f48c40ff211487/yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7", size = 81198, upload-time = "2025-06-10T00:44:49.164Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/ba/39b1ecbf51620b40ab402b0fc817f0ff750f6d92712b44689c2c215be89d/yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c", size = 86346, upload-time = "2025-06-10T00:44:51.182Z" },
+ { url = "https://files.pythonhosted.org/packages/43/c7/669c52519dca4c95153c8ad96dd123c79f354a376346b198f438e56ffeb4/yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d", size = 138826, upload-time = "2025-06-10T00:44:52.883Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/42/fc0053719b44f6ad04a75d7f05e0e9674d45ef62f2d9ad2c1163e5c05827/yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf", size = 93217, upload-time = "2025-06-10T00:44:54.658Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/7f/fa59c4c27e2a076bba0d959386e26eba77eb52ea4a0aac48e3515c186b4c/yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3", size = 92700, upload-time = "2025-06-10T00:44:56.784Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/d4/062b2f48e7c93481e88eff97a6312dca15ea200e959f23e96d8ab898c5b8/yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d", size = 347644, upload-time = "2025-06-10T00:44:59.071Z" },
+ { url = "https://files.pythonhosted.org/packages/89/47/78b7f40d13c8f62b499cc702fdf69e090455518ae544c00a3bf4afc9fc77/yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c", size = 323452, upload-time = "2025-06-10T00:45:01.605Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/2b/490d3b2dc66f52987d4ee0d3090a147ea67732ce6b4d61e362c1846d0d32/yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1", size = 346378, upload-time = "2025-06-10T00:45:03.946Z" },
+ { url = "https://files.pythonhosted.org/packages/66/ad/775da9c8a94ce925d1537f939a4f17d782efef1f973039d821cbe4bcc211/yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce", size = 353261, upload-time = "2025-06-10T00:45:05.992Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/23/0ed0922b47a4f5c6eb9065d5ff1e459747226ddce5c6a4c111e728c9f701/yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3", size = 335987, upload-time = "2025-06-10T00:45:08.227Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/49/bc728a7fe7d0e9336e2b78f0958a2d6b288ba89f25a1762407a222bf53c3/yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be", size = 329361, upload-time = "2025-06-10T00:45:10.11Z" },
+ { url = "https://files.pythonhosted.org/packages/93/8f/b811b9d1f617c83c907e7082a76e2b92b655400e61730cd61a1f67178393/yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16", size = 346460, upload-time = "2025-06-10T00:45:12.055Z" },
+ { url = "https://files.pythonhosted.org/packages/70/fd/af94f04f275f95da2c3b8b5e1d49e3e79f1ed8b6ceb0f1664cbd902773ff/yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513", size = 334486, upload-time = "2025-06-10T00:45:13.995Z" },
+ { url = "https://files.pythonhosted.org/packages/84/65/04c62e82704e7dd0a9b3f61dbaa8447f8507655fd16c51da0637b39b2910/yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f", size = 342219, upload-time = "2025-06-10T00:45:16.479Z" },
+ { url = "https://files.pythonhosted.org/packages/91/95/459ca62eb958381b342d94ab9a4b6aec1ddec1f7057c487e926f03c06d30/yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390", size = 350693, upload-time = "2025-06-10T00:45:18.399Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/00/d393e82dd955ad20617abc546a8f1aee40534d599ff555ea053d0ec9bf03/yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458", size = 355803, upload-time = "2025-06-10T00:45:20.677Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ed/c5fb04869b99b717985e244fd93029c7a8e8febdfcffa06093e32d7d44e7/yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e", size = 341709, upload-time = "2025-06-10T00:45:23.221Z" },
+ { url = "https://files.pythonhosted.org/packages/24/fd/725b8e73ac2a50e78a4534ac43c6addf5c1c2d65380dd48a9169cc6739a9/yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d", size = 86591, upload-time = "2025-06-10T00:45:25.793Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" },
+]
diff --git a/server/docs/gpu/api-transcription.md b/server/docs/gpu/api-transcription.md
index 7a15d793..70b542cc 100644
--- a/server/docs/gpu/api-transcription.md
+++ b/server/docs/gpu/api-transcription.md
@@ -190,5 +190,5 @@ Use the pytest-based conformance tests to validate any new implementation (inclu
```
TRANSCRIPT_URL=https:// \
TRANSCRIPT_MODAL_API_KEY=your-api-key \
-uv run -m pytest -m gpu_modal --no-cov server/tests/test_gpu_modal_transcript.py
+uv run -m pytest -m model_api --no-cov server/tests/test_model_api_transcript.py
```
diff --git a/server/pyproject.toml b/server/pyproject.toml
index d055f461..1609afe0 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -118,7 +118,7 @@ addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v"
testpaths = ["tests"]
asyncio_mode = "auto"
markers = [
- "gpu_modal: mark test to run only with GPU Modal endpoints (deselect with '-m \"not gpu_modal\"')",
+ "model_api: tests for the unified model-serving HTTP API (backend- and hardware-agnostic)",
]
[tool.ruff.lint]
@@ -130,7 +130,7 @@ select = [
[tool.ruff.lint.per-file-ignores]
"reflector/processors/summary/summary_builder.py" = ["E501"]
-"gpu/**.py" = ["PLC0415"]
+"gpu/modal_deployments/**.py" = ["PLC0415"]
"reflector/tools/**.py" = ["PLC0415"]
"migrations/versions/**.py" = ["PLC0415"]
"tests/**.py" = ["PLC0415"]
diff --git a/server/tests/test_model_api_diarization.py b/server/tests/test_model_api_diarization.py
new file mode 100644
index 00000000..8ae719ff
--- /dev/null
+++ b/server/tests/test_model_api_diarization.py
@@ -0,0 +1,63 @@
+"""
+Tests for diarization Model API endpoint (self-hosted service compatible shape).
+
+Marked with the "model_api" marker and skipped unless DIARIZATION_URL is provided.
+
+Run with for local self-hosted server:
+ DIARIZATION_API_KEY=dev-key \
+ DIARIZATION_URL=http://localhost:8000 \
+ uv run -m pytest -m model_api --no-cov tests/test_model_api_diarization.py
+"""
+
+import os
+
+import httpx
+import pytest
+
+# Public test audio file hosted on S3 specifically for reflector pytests
+TEST_AUDIO_URL = (
+ "https://reflector-github-pytest.s3.us-east-1.amazonaws.com/test_mathieu_hello.mp3"
+)
+
+
+def get_modal_diarization_url():
+ url = os.environ.get("DIARIZATION_URL")
+ if not url:
+ pytest.skip(
+ "DIARIZATION_URL environment variable is required for Model API tests"
+ )
+ return url
+
+
+def get_auth_headers():
+ api_key = os.environ.get("DIARIZATION_API_KEY") or os.environ.get(
+ "REFLECTOR_GPU_APIKEY"
+ )
+ return {"Authorization": f"Bearer {api_key}"} if api_key else {}
+
+
+@pytest.mark.model_api
+class TestModelAPIDiarization:
+ def test_diarize_from_url(self):
+ url = get_modal_diarization_url()
+ headers = get_auth_headers()
+
+ with httpx.Client(timeout=60.0) as client:
+ response = client.post(
+ f"{url}/diarize",
+ params={"audio_file_url": TEST_AUDIO_URL, "timestamp": 0.0},
+ headers=headers,
+ )
+
+ assert response.status_code == 200, f"Request failed: {response.text}"
+ result = response.json()
+
+ assert "diarization" in result
+ assert isinstance(result["diarization"], list)
+ assert len(result["diarization"]) > 0
+
+ for seg in result["diarization"]:
+ assert "start" in seg and "end" in seg and "speaker" in seg
+ assert isinstance(seg["start"], (int, float))
+ assert isinstance(seg["end"], (int, float))
+ assert seg["start"] <= seg["end"]
diff --git a/server/tests/test_gpu_modal_transcript.py b/server/tests/test_model_api_transcript.py
similarity index 94%
rename from server/tests/test_gpu_modal_transcript.py
rename to server/tests/test_model_api_transcript.py
index 9a152185..f4a21283 100644
--- a/server/tests/test_gpu_modal_transcript.py
+++ b/server/tests/test_model_api_transcript.py
@@ -1,21 +1,21 @@
"""
-Tests for GPU Modal transcription endpoints.
+Tests for transcription Model API endpoints.
-These tests are marked with the "gpu-modal" group and will not run by default.
-Run them with: pytest -m gpu-modal tests/test_gpu_modal_transcript_parakeet.py
+These tests are marked with the "model_api" group and will not run by default.
+Run them with: pytest -m model_api tests/test_model_api_transcript.py
Required environment variables:
-- TRANSCRIPT_URL: URL to the Modal.com endpoint (required)
-- TRANSCRIPT_MODAL_API_KEY: API key for authentication (optional)
+- TRANSCRIPT_URL: URL to the Model API endpoint (required)
+- TRANSCRIPT_API_KEY: API key for authentication (optional)
- TRANSCRIPT_MODEL: Model name to use (optional, defaults to nvidia/parakeet-tdt-0.6b-v2)
-Example with pytest (override default addopts to run ONLY gpu_modal tests):
+Example with pytest (override default addopts to run ONLY model_api tests):
TRANSCRIPT_URL=https://monadical-sas--reflector-transcriber-parakeet-web-dev.modal.run \
- TRANSCRIPT_MODAL_API_KEY=your-api-key \
- uv run -m pytest -m gpu_modal --no-cov tests/test_gpu_modal_transcript.py
+ TRANSCRIPT_API_KEY=your-api-key \
+ uv run -m pytest -m model_api --no-cov tests/test_model_api_transcript.py
# Or with completely clean options:
- uv run -m pytest -m gpu_modal -o addopts="" tests/
+ uv run -m pytest -m model_api -o addopts="" tests/
Running Modal locally for testing:
modal serve gpu/modal_deployments/reflector_transcriber_parakeet.py
@@ -40,14 +40,16 @@ def get_modal_transcript_url():
url = os.environ.get("TRANSCRIPT_URL")
if not url:
pytest.skip(
- "TRANSCRIPT_URL environment variable is required for GPU Modal tests"
+ "TRANSCRIPT_URL environment variable is required for Model API tests"
)
return url
def get_auth_headers():
"""Get authentication headers if API key is available."""
- api_key = os.environ.get("TRANSCRIPT_MODAL_API_KEY")
+ api_key = os.environ.get("TRANSCRIPT_API_KEY") or os.environ.get(
+ "REFLECTOR_GPU_APIKEY"
+ )
if api_key:
return {"Authorization": f"Bearer {api_key}"}
return {}
@@ -58,8 +60,8 @@ def get_model_name():
return os.environ.get("TRANSCRIPT_MODEL", "nvidia/parakeet-tdt-0.6b-v2")
-@pytest.mark.gpu_modal
-class TestGPUModalTranscript:
+@pytest.mark.model_api
+class TestModelAPITranscript:
"""Test suite for GPU Modal transcription endpoints."""
def test_transcriptions_from_url(self):
diff --git a/server/tests/test_model_api_translation.py b/server/tests/test_model_api_translation.py
new file mode 100644
index 00000000..47e3141f
--- /dev/null
+++ b/server/tests/test_model_api_translation.py
@@ -0,0 +1,56 @@
+"""
+Tests for translation Model API endpoint (self-hosted service compatible shape).
+
+Marked with the "model_api" marker and skipped unless TRANSLATION_URL is provided
+or we fallback to TRANSCRIPT_URL base (same host for self-hosted).
+
+Run locally against self-hosted server:
+ TRANSLATION_API_KEY=dev-key \
+ TRANSLATION_URL=http://localhost:8000 \
+ uv run -m pytest -m model_api --no-cov tests/test_model_api_translation.py
+"""
+
+import os
+
+import httpx
+import pytest
+
+
+def get_translation_url():
+ url = os.environ.get("TRANSLATION_URL") or os.environ.get("TRANSCRIPT_URL")
+ if not url:
+ pytest.skip(
+ "TRANSLATION_URL or TRANSCRIPT_URL environment variable is required for Model API tests"
+ )
+ return url
+
+
+def get_auth_headers():
+ api_key = os.environ.get("TRANSLATION_API_KEY") or os.environ.get(
+ "REFLECTOR_GPU_APIKEY"
+ )
+ return {"Authorization": f"Bearer {api_key}"} if api_key else {}
+
+
+@pytest.mark.model_api
+class TestModelAPITranslation:
+ def test_translate_text(self):
+ url = get_translation_url()
+ headers = get_auth_headers()
+
+ with httpx.Client(timeout=60.0) as client:
+ response = client.post(
+ f"{url}/translate",
+ params={"text": "The meeting will start in five minutes."},
+ json={"source_language": "en", "target_language": "fr"},
+ headers=headers,
+ )
+
+ assert response.status_code == 200, f"Request failed: {response.text}"
+ data = response.json()
+
+ assert "text" in data and isinstance(data["text"], dict)
+ assert data["text"].get("en") == "The meeting will start in five minutes."
+ assert isinstance(data["text"].get("fr", ""), str)
+ assert len(data["text"]["fr"]) > 0
+ assert data["text"]["fr"] == "La réunion commencera dans cinq minutes."
From 6f680b57954c688882c4ed49f40f161c52a00a24 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Wed, 17 Sep 2025 16:43:20 -0600
Subject: [PATCH 37/77] feat: calendar integration (#608)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: calendar integration
* feat: add ICS calendar API endpoints for room configuration and sync
* feat: add Celery background tasks for ICS sync
* feat: implement Phase 2 - Multiple active meetings per room with grace period
This commit adds support for multiple concurrent meetings per room, implementing
grace period logic and improved meeting lifecycle management for calendar integration.
## Database Changes
- Remove unique constraint preventing multiple active meetings per room
- Add last_participant_left_at field to track when meeting becomes empty
- Add grace_period_minutes field (default: 15) for configurable grace period
## Meeting Controller Enhancements
- Add get_all_active_for_room() to retrieve all active meetings for a room
- Add get_active_by_calendar_event() to find meetings by calendar event ID
- Maintain backward compatibility with existing get_active() method
## New API Endpoints
- GET /rooms/{room_name}/meetings/active - List all active meetings
- POST /rooms/{room_name}/meetings/{meeting_id}/join - Join specific meeting
## Meeting Lifecycle Improvements
- 15-minute grace period after last participant leaves
- Automatic reactivation when participant rejoins during grace period
- Force close calendar meetings 30 minutes after scheduled end time
- Update process_meetings task to handle multiple active meetings
## Whereby Integration
- Clear grace period when participants join via webhook events
- Track participant count for grace period management
## Testing
- Add comprehensive tests for multiple active meetings
- Test grace period behavior and participant rejoin scenarios
- Test calendar meeting force closure logic
- All 5 new tests passing
This enables proper calendar integration with overlapping meetings while
preventing accidental meeting closures through the grace period mechanism.
* feat: implement frontend for calendar integration (Phase 3 & 4)
- Created MeetingSelection component for choosing between multiple active meetings
- Shows both active meetings and upcoming calendar events (30 min ahead)
- Displays meeting metadata with privacy controls (owner-only details)
- Supports creation of unscheduled meetings alongside calendar meetings
- Added waiting page for users joining before scheduled start time
- Shows countdown timer until meeting begins
- Auto-transitions to meeting when calendar event becomes active
- Handles early joining with proper routing
- Created collapsible info panel showing meeting details
- Displays calendar metadata (title, description, attendees)
- Shows participant count and duration
- Privacy-aware: sensitive info only visible to room owners
- Integrated ICS settings into room configuration dialog
- Test connection functionality with immediate feedback
- Manual sync trigger with detailed results
- Shows last sync time and ETag for monitoring
- Configurable sync intervals (1 min to 1 hour)
- New /room/{roomName} route for meeting selection
- Waiting room at /room/{roomName}/wait?eventId={id}
- Classic room page at /{roomName} with meeting info
- Uses sessionStorage to pass selected meeting between pages
- Added new endpoints for active/upcoming meetings
- Regenerated TypeScript client with latest OpenAPI spec
- Proper error handling and loading states
- Auto-refresh every 30 seconds for live updates
- Color-coded badges for meeting status
- Attendee status indicators (accepted/declined/tentative)
- Responsive design with Chakra UI components
- Clear visual hierarchy between active and upcoming meetings
- Smart truncation for long attendee lists
This completes the frontend implementation for calendar integration,
enabling users to seamlessly join scheduled meetings from their
calendar applications.
* WIP: Migrate calendar integration frontend to React Query
- Migrate all calendar components from useApi to React Query hooks
- Fix Chakra UI v3 compatibility issues (Card, Progress, spacing props, leftIcon)
- Update backend Meeting model to include calendar fields
- Replace imperative API calls with declarative React Query patterns
- Remove old OpenAPI generated files that conflict with new structure
* fix: alembic migrations
* feat: add calendar migration
* feat: update ics, first version working
* feat: implement tabbed interface for room edit dialog
- Add General, Calendar, and Share tabs to organize room settings
- Move ICS settings to dedicated Calendar tab
- Move Zulip configuration to Share tab
- Keep basic room settings and webhooks in General tab
- Remove redundant migration file
- Fix Chakra UI v3 compatibility issues in calendar components
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
* fix: infinite loop
* feat: improve ICS calendar sync UX and fix room URL matching
- Replace "Test Connection" button with "Force Sync" button (Edit Room only)
- Show detailed sync results: total events downloaded vs room matches
- Remove emoticons and auto-hide timeout for cleaner UX
- Fix room URL matching to use UI_BASE_URL instead of BASE_URL
- Replace FaSync icon with LuRefreshCw for consistency
- Clear sync results when dialog closes or Force Sync pressed
- Update tests to reflect UI_BASE_URL change and exact URL matching
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
* feat: reorganize room edit dialog and fix Force Sync button
- Move WebHook configuration from General to dedicated WebHook tab
- Add WebHook tab after Share tab in room edit dialog
- Fix Force Sync button not appearing by adding missing isEditing prop
- Fix indentation issues in MeetingSelection component
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
* feat: complete calendar integration with UI improvements and code cleanup
Calendar Integration Tasks:
- Update upcoming meetings window from 30 to 120 minutes
- Include currently happening events in upcoming meetings API
- Create shared time utility functions (formatDateTime, formatCountdown, formatStartedAgo)
- Improve ongoing meetings UI logic with proper time detection
- Fix backend code organization and remove excessive documentation
UI/UX Improvements:
- Restructure room page layout using MinimalHeader pattern
- Remove borders from header and footer elements
- Change button text from "Leave Meeting" to "Leave Room"
- Remove "Back to Reflector" footer for cleaner design
- Extract WaitPageClient component for better separation
Backend Changes:
- calendar_events.py: Fix import organization and extend timing window
- rooms.py: Update API default from 30 to 120 minutes
- Enhanced test coverage for ongoing meeting scenarios
Frontend Changes:
- MinimalHeader: Add onLeave prop for custom navigation
- MeetingSelection: Complete layout restructure with shared utilities
- timeUtils: New shared utility file for consistent time formatting
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
* feat: remove wait page and simplify Join button with 5-minute disable logic
- Remove entire wait page directory and associated files
- Update handleJoinUpcoming to create unscheduled meeting directly
- Simplify Join button to single state:
- Always shows "Join" text
- Blue when meeting can be joined (ongoing or within 5 minutes)
- Gray/disabled when more than 5 minutes away
- Remove confusing "Join Now", "Join Early" text variations
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
* feat: improve calendar integration and meeting UI
- Refactor ICS sync tasks to use @asynctask decorator for cleaner async handling
- Extract meeting creation logic into reusable function
- Improve meeting selection UI with distinct current/upcoming sections
- Add early join functionality for upcoming meetings within 5-minute window
- Simplify non-ICS room workflow with direct Whereby embed
- Fix import paths and component organization
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude
* feat: restore original recording consent functionality
- Remove custom ConsentDialogButton and WherebyEmbed components
- Merge RoomClient logic back into main room page
- Restore original consent UI: blue button with toast modal
- Maintain calendar integration features for ICS-enabled rooms
- Add consent-handler.md documentation of original functionality
- Preserve focus management and accessibility features
* fix: redirect Join Now button to local meeting page
- Change handleJoinDirect to use onMeetingSelect instead of opening external URL
- Join Now button now navigates to /{roomName}/{meetingId} instead of whereby.com
- Maintains proper routing within the application
* feat: remove restrictive message for non-owners in private rooms
- Remove confusing message about room owner permissions
- Cleaner UI for all users regardless of ownership status
- Users will only see available meetings and join options
* feat: improve meeting selection UI for better readability
- Limit page content to max 800px width for better 4K display readability
- Remove LIVE tag badge for cleaner interface
- Remove shadow from main live meeting box
- Remove blue border and hover effects for minimal design
- Change background to neutral gray for less visual noise
* feat: add room by name endpoint for non-authenticated access
- Add GET /rooms/name/{room_name} backend endpoint
- Endpoint supports non-authenticated access for public rooms
- Returns RoomDetails with webhook fields hidden for non-owners
- Update useRoomGetByName hook to use new direct endpoint
- Remove authentication requirement from frontend hook
- Regenerate API client types
Fixes: Non-authenticated users can now access room lobbies
* feat: add friendly message when no meetings are ongoing
- Show centered message with calendar icon when no meetings are active
- Message text: 'No meetings right now' with helpful description
- Contextual text for owners/shared rooms mentioning quick meeting option
- Consistent gray styling matching the rest of the interface
- Only displays when both currentMeetings and upcomingMeetings are empty
* style: center no meetings message and remove background
- Change from Box to Flex with flex=1 for vertical centering
- Remove gray background, border radius, and padding
- Message now appears cleanly centered in available space
- Maintains horizontal and vertical centering
* feat: move Create Meeting button to header
- Remove 'Start a Quick Meeting' box from main content area
- Add showCreateButton and onCreateMeeting props to MinimalHeader
- Create Meeting button now appears in header left of Leave Room
- Only shows for room owners or shared room users
- Update no meetings message to remove reference to quick meeting below
- Cleaner, more accessible UI with actions in the header
* style: change room title and no meetings text to pure black
- Update room title in MinimalHeader from gray.700 to black
- Update 'No meetings right now' text from gray.700 to black
- Improves visual hierarchy and readability
- Consistent with other pages' styling
* style: linting
* fix: remove plan files
* fix: alembic migration with named foreign keys
* feat: add SyncStatus enum and refactor ICS sync to use rooms controller
- Add SyncStatus enum to replace string literals in ICS sync status
- Replace direct SQL queries in worker with rooms_controller.get_ics_enabled()
- Improve type safety and maintainability of ICS sync code
- Enum values: SUCCESS, UNCHANGED, ERROR, SKIPPED maintain backward compatibility
* refactor: remove unnecessary docstring from get_ics_enabled method
The function name is self-explanatory
* fix: import top level
* feat: use Literal type for ICSStatus.status field
- Changed ICSStatus.status from str to Literal['enabled', 'disabled']
- Improves type safety and API documentation
* feat: update TypeScript definitions for ICSStatus Literal type
- OpenAPI generation now properly reflects Literal['enabled', 'disabled'] type
- Improves type safety for frontend consumers of the API
- Applied automatic formatting via pre-commit hooks
* refactor: replace loguru with structlog in ics_sync service
- Replace loguru import with structlog in services/ics_sync.py
- Update logging calls to use structlog's structured format with keyword args
- Maintains consistency with other services using structlog
- Changes: logger.info(f'...') -> logger.info('...', key=value) format
* chore: remove loguru dependency and improve type annotations
- Remove loguru from dependencies in pyproject.toml (replaced with structlog)
- Update meeting controller methods to properly return Optional types
- Update dependency lock file after loguru removal
* fix: resolve pyflakes warnings in ics_sync and meetings modules
Remove unused imports and variables to clean up code quality
* Remove grace period logic and improve meeting deactivation
- Removed grace_period_minutes and last_participant_left_at fields
- Simplified deactivation logic based on actual usage patterns:
* Active sessions: Keep meeting active regardless of scheduled time
* Calendar meetings: Wait until scheduled end if unused, deactivate immediately once used and empty
* On-the-fly meetings: Deactivate immediately when empty
- Created migration to drop unused database columns
- Updated tests to remove grace period test cases
* Update test to match new deactivation logic for calendar meetings
* fix: remove unwanted file
* fix: incompleted changes from EVENT_WINDOW*
* fix: update room ICS API tests to include required webhook fields and correct URL
- Add webhook_url and webhook_secret fields to room creation tests
- Fix room URL matching in ICS sync test to use UI_BASE_URL instead of BASE_URL
- Aligns test with actual API requirements and ICS sync service implementation
* fix: add Redis distributed locking to prevent race conditions in process_meetings
- Implement per-meeting locks using Redis to prevent concurrent processing
- Add lock extension after slow API calls (Whereby) to handle long-running operations
- Use redis-py's built-in lock.extend() with replace_ttl=True for simple TTL refresh
- Track and log skipped meetings when locked by other workers
- Document SSRF analysis showing it's low-risk due to async worker isolation
This prevents multiple workers from processing the same meeting simultaneously,
which could cause state corruption or duplicate deactivations.
* refactor: rename MinimalHeader to MeetingMinimalHeader for clarity
* fix: minor code quality improvements - add emoji constants, fix type safety, cleanup comments
* fix: database migration
* self-pr review
* self-pr review
* self-pr review treeshake
* fix: local fixes
* fix: creation of meeting
* fix: meeting selection create button
* compile fix
* fix: meeting selection responsive
* fix: rework process logic for meeting
* fix: meeting useEffect frontend-only dedupe (#647)
* meeting useEffect frontend-only dedupe
* format
* also get room by name backend fix
---------
Co-authored-by: Igor Loskutov
* invalidate meeting list on new meeting
* test fix
* room url copy button for ics
* calendar refresh quick action icon
* remove work.md
* meeting page frontend fixes
* hide number of meeting participants
* Revert "hide number of meeting participants"
This reverts commit 38906c5d1a20bf5938d73ca7133fbd4a51438ce6.
* ui bits
* ui bits
* remove log
* room name typing stricten
* feat: protect atomic operation involving external service with redlock
---------
Co-authored-by: Claude
Co-authored-by: Igor Monadical
Co-authored-by: Igor Loskutov
---
...ef2_remove_one_active_meeting_per_room_.py | 53 ++
...458c_add_grace_period_fields_to_meeting.py | 34 +
.../versions/d8e204bbf615_add_calendar.py | 129 +++
...dc035ff72fd5_remove_grace_period_fields.py | 43 +
server/pyproject.toml | 2 +-
server/reflector/auth/auth_jwt.py | 3 +-
server/reflector/db/__init__.py | 1 +
server/reflector/db/calendar_events.py | 182 ++++
server/reflector/db/meetings.py | 89 +-
server/reflector/db/rooms.py | 26 +
server/reflector/redis_cache.py | 97 +++
server/reflector/services/ics_sync.py | 408 +++++++++
server/reflector/views/meetings.py | 32 +
server/reflector/views/rooms.py | 422 ++++++++--
server/reflector/views/whereby.py | 5 +-
server/reflector/worker/app.py | 9 +
server/reflector/worker/ics_sync.py | 175 ++++
server/reflector/worker/process.py | 92 +-
server/test.ics | 29 +
server/tests/test_attendee_parsing_bug.ics | 18 +
server/tests/test_attendee_parsing_bug.py | 192 +++++
server/tests/test_calendar_event.py | 424 ++++++++++
server/tests/test_ics_background_tasks.py | 255 ++++++
server/tests/test_ics_sync.py | 290 +++++++
server/tests/test_multiple_active_meetings.py | 167 ++++
server/tests/test_room_ics.py | 225 +++++
server/tests/test_room_ics_api.py | 390 +++++++++
server/uv.lock | 181 ++--
.../(app)/rooms/_components/ICSSettings.tsx | 343 ++++++++
www/app/(app)/rooms/_components/RoomList.tsx | 3 +-
www/app/(app)/rooms/_components/RoomTable.tsx | 177 +++-
www/app/(app)/rooms/page.tsx | 783 ++++++++++--------
www/app/[roomName]/MeetingSelection.tsx | 569 +++++++++++++
www/app/[roomName]/[meetingId]/constants.ts | 1 +
www/app/[roomName]/[meetingId]/page.tsx | 3 +
www/app/[roomName]/page.tsx | 337 +-------
www/app/[roomName]/room.tsx | 437 ++++++++++
...mMeeting.tsx => useRoomDefaultMeeting.tsx} | 29 +-
www/app/api/_error.ts | 26 +
www/app/api/schemas.gen.ts | 0
www/app/api/services.gen.ts | 0
www/app/api/types.gen.ts | 0
www/app/components/MeetingMinimalHeader.tsx | 101 +++
www/app/lib/WherebyWebinarEmbed.tsx | 6 +-
www/app/lib/apiHooks.ts | 300 +++++--
www/app/lib/routes.ts | 7 +
www/app/lib/routesClient.ts | 5 +
www/app/lib/timeUtils.ts | 25 +
www/app/lib/wherebyClient.ts | 22 +
www/app/reflector-api.d.ts | 671 ++++++++++++++-
www/app/webinars/[title]/page.tsx | 4 +-
www/package.json | 1 +
www/pnpm-lock.yaml | 13 +
53 files changed, 6876 insertions(+), 960 deletions(-)
create mode 100644 server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py
create mode 100644 server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py
create mode 100644 server/migrations/versions/d8e204bbf615_add_calendar.py
create mode 100644 server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py
create mode 100644 server/reflector/db/calendar_events.py
create mode 100644 server/reflector/services/ics_sync.py
create mode 100644 server/reflector/worker/ics_sync.py
create mode 100644 server/test.ics
create mode 100644 server/tests/test_attendee_parsing_bug.ics
create mode 100644 server/tests/test_attendee_parsing_bug.py
create mode 100644 server/tests/test_calendar_event.py
create mode 100644 server/tests/test_ics_background_tasks.py
create mode 100644 server/tests/test_ics_sync.py
create mode 100644 server/tests/test_multiple_active_meetings.py
create mode 100644 server/tests/test_room_ics.py
create mode 100644 server/tests/test_room_ics_api.py
create mode 100644 www/app/(app)/rooms/_components/ICSSettings.tsx
create mode 100644 www/app/[roomName]/MeetingSelection.tsx
create mode 100644 www/app/[roomName]/[meetingId]/constants.ts
create mode 100644 www/app/[roomName]/[meetingId]/page.tsx
create mode 100644 www/app/[roomName]/room.tsx
rename www/app/[roomName]/{useRoomMeeting.tsx => useRoomDefaultMeeting.tsx} (75%)
create mode 100644 www/app/api/_error.ts
delete mode 100644 www/app/api/schemas.gen.ts
delete mode 100644 www/app/api/services.gen.ts
delete mode 100644 www/app/api/types.gen.ts
create mode 100644 www/app/components/MeetingMinimalHeader.tsx
create mode 100644 www/app/lib/routes.ts
create mode 100644 www/app/lib/routesClient.ts
create mode 100644 www/app/lib/timeUtils.ts
create mode 100644 www/app/lib/wherebyClient.ts
diff --git a/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py b/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py
new file mode 100644
index 00000000..4c6e2f7b
--- /dev/null
+++ b/server/migrations/versions/6025e9b2bef2_remove_one_active_meeting_per_room_.py
@@ -0,0 +1,53 @@
+"""remove_one_active_meeting_per_room_constraint
+
+Revision ID: 6025e9b2bef2
+Revises: 2ae3db106d4e
+Create Date: 2025-08-18 18:45:44.418392
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "6025e9b2bef2"
+down_revision: Union[str, None] = "2ae3db106d4e"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Remove the unique constraint that prevents multiple active meetings per room
+ # This is needed to support calendar integration with overlapping meetings
+ # Check if index exists before trying to drop it
+ from alembic import context
+
+ if context.get_context().dialect.name == "postgresql":
+ conn = op.get_bind()
+ result = conn.execute(
+ sa.text(
+ "SELECT 1 FROM pg_indexes WHERE indexname = 'idx_one_active_meeting_per_room'"
+ )
+ )
+ if result.fetchone():
+ op.drop_index("idx_one_active_meeting_per_room", table_name="meeting")
+ else:
+ # For SQLite, just try to drop it
+ try:
+ op.drop_index("idx_one_active_meeting_per_room", table_name="meeting")
+ except:
+ pass
+
+
+def downgrade() -> None:
+ # Restore the unique constraint
+ op.create_index(
+ "idx_one_active_meeting_per_room",
+ "meeting",
+ ["room_id"],
+ unique=True,
+ postgresql_where=sa.text("is_active = true"),
+ sqlite_where=sa.text("is_active = 1"),
+ )
diff --git a/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py b/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py
new file mode 100644
index 00000000..868e3479
--- /dev/null
+++ b/server/migrations/versions/d4a1c446458c_add_grace_period_fields_to_meeting.py
@@ -0,0 +1,34 @@
+"""add_grace_period_fields_to_meeting
+
+Revision ID: d4a1c446458c
+Revises: 6025e9b2bef2
+Create Date: 2025-08-18 18:50:37.768052
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "d4a1c446458c"
+down_revision: Union[str, None] = "6025e9b2bef2"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Add fields to track when participants left for grace period logic
+ op.add_column(
+ "meeting", sa.Column("last_participant_left_at", sa.DateTime(timezone=True))
+ )
+ op.add_column(
+ "meeting",
+ sa.Column("grace_period_minutes", sa.Integer, server_default=sa.text("15")),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("meeting", "grace_period_minutes")
+ op.drop_column("meeting", "last_participant_left_at")
diff --git a/server/migrations/versions/d8e204bbf615_add_calendar.py b/server/migrations/versions/d8e204bbf615_add_calendar.py
new file mode 100644
index 00000000..a134989d
--- /dev/null
+++ b/server/migrations/versions/d8e204bbf615_add_calendar.py
@@ -0,0 +1,129 @@
+"""add calendar
+
+Revision ID: d8e204bbf615
+Revises: d4a1c446458c
+Create Date: 2025-09-10 19:56:22.295756
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = "d8e204bbf615"
+down_revision: Union[str, None] = "d4a1c446458c"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table(
+ "calendar_event",
+ sa.Column("id", sa.String(), nullable=False),
+ sa.Column("room_id", sa.String(), nullable=False),
+ sa.Column("ics_uid", sa.Text(), nullable=False),
+ sa.Column("title", sa.Text(), nullable=True),
+ sa.Column("description", sa.Text(), nullable=True),
+ sa.Column("start_time", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("end_time", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("attendees", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
+ sa.Column("location", sa.Text(), nullable=True),
+ sa.Column("ics_raw_data", sa.Text(), nullable=True),
+ sa.Column("last_synced", sa.DateTime(timezone=True), nullable=False),
+ sa.Column(
+ "is_deleted", sa.Boolean(), server_default=sa.text("false"), nullable=False
+ ),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["room_id"],
+ ["room.id"],
+ name="fk_calendar_event_room_id",
+ ondelete="CASCADE",
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("room_id", "ics_uid", name="uq_room_calendar_event"),
+ )
+ with op.batch_alter_table("calendar_event", schema=None) as batch_op:
+ batch_op.create_index(
+ "idx_calendar_event_deleted",
+ ["is_deleted"],
+ unique=False,
+ postgresql_where=sa.text("NOT is_deleted"),
+ )
+ batch_op.create_index(
+ "idx_calendar_event_room_start", ["room_id", "start_time"], unique=False
+ )
+
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("calendar_event_id", sa.String(), nullable=True))
+ batch_op.add_column(
+ sa.Column(
+ "calendar_metadata",
+ postgresql.JSONB(astext_type=sa.Text()),
+ nullable=True,
+ )
+ )
+ batch_op.create_index(
+ "idx_meeting_calendar_event", ["calendar_event_id"], unique=False
+ )
+ batch_op.create_foreign_key(
+ "fk_meeting_calendar_event_id",
+ "calendar_event",
+ ["calendar_event_id"],
+ ["id"],
+ ondelete="SET NULL",
+ )
+
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("ics_url", sa.Text(), nullable=True))
+ batch_op.add_column(
+ sa.Column(
+ "ics_fetch_interval", sa.Integer(), server_default="300", nullable=True
+ )
+ )
+ batch_op.add_column(
+ sa.Column(
+ "ics_enabled",
+ sa.Boolean(),
+ server_default=sa.text("false"),
+ nullable=False,
+ )
+ )
+ batch_op.add_column(
+ sa.Column("ics_last_sync", sa.DateTime(timezone=True), nullable=True)
+ )
+ batch_op.add_column(sa.Column("ics_last_etag", sa.Text(), nullable=True))
+ batch_op.create_index("idx_room_ics_enabled", ["ics_enabled"], unique=False)
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.drop_index("idx_room_ics_enabled")
+ batch_op.drop_column("ics_last_etag")
+ batch_op.drop_column("ics_last_sync")
+ batch_op.drop_column("ics_enabled")
+ batch_op.drop_column("ics_fetch_interval")
+ batch_op.drop_column("ics_url")
+
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.drop_constraint("fk_meeting_calendar_event_id", type_="foreignkey")
+ batch_op.drop_index("idx_meeting_calendar_event")
+ batch_op.drop_column("calendar_metadata")
+ batch_op.drop_column("calendar_event_id")
+
+ with op.batch_alter_table("calendar_event", schema=None) as batch_op:
+ batch_op.drop_index("idx_calendar_event_room_start")
+ batch_op.drop_index(
+ "idx_calendar_event_deleted", postgresql_where=sa.text("NOT is_deleted")
+ )
+
+ op.drop_table("calendar_event")
+ # ### end Alembic commands ###
diff --git a/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py b/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py
new file mode 100644
index 00000000..c38a0227
--- /dev/null
+++ b/server/migrations/versions/dc035ff72fd5_remove_grace_period_fields.py
@@ -0,0 +1,43 @@
+"""remove_grace_period_fields
+
+Revision ID: dc035ff72fd5
+Revises: d8e204bbf615
+Create Date: 2025-09-11 10:36:45.197588
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "dc035ff72fd5"
+down_revision: Union[str, None] = "d8e204bbf615"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Remove grace period columns from meeting table
+ op.drop_column("meeting", "last_participant_left_at")
+ op.drop_column("meeting", "grace_period_minutes")
+
+
+def downgrade() -> None:
+ # Add back grace period columns to meeting table
+ op.add_column(
+ "meeting",
+ sa.Column(
+ "last_participant_left_at", sa.DateTime(timezone=True), nullable=True
+ ),
+ )
+ op.add_column(
+ "meeting",
+ sa.Column(
+ "grace_period_minutes",
+ sa.Integer(),
+ server_default=sa.text("15"),
+ nullable=True,
+ ),
+ )
diff --git a/server/pyproject.toml b/server/pyproject.toml
index 1609afe0..f63947c8 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -12,7 +12,6 @@ dependencies = [
"requests>=2.31.0",
"aiortc>=1.5.0",
"sortedcontainers>=2.4.0",
- "loguru>=0.7.0",
"pydantic-settings>=2.0.2",
"structlog>=23.1.0",
"uvicorn[standard]>=0.23.1",
@@ -39,6 +38,7 @@ dependencies = [
"llama-index-llms-openai-like>=0.4.0",
"pytest-env>=1.1.5",
"webvtt-py>=0.5.0",
+ "icalendar>=6.0.0",
]
[dependency-groups]
diff --git a/server/reflector/auth/auth_jwt.py b/server/reflector/auth/auth_jwt.py
index 4cc8ba03..309ab3f7 100644
--- a/server/reflector/auth/auth_jwt.py
+++ b/server/reflector/auth/auth_jwt.py
@@ -67,7 +67,8 @@ def current_user(
try:
payload = jwtauth.verify_token(token)
sub = payload["sub"]
- return UserInfo(sub=sub)
+ email = payload["email"]
+ return UserInfo(sub=sub, 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 da488a51..f79a2573 100644
--- a/server/reflector/db/__init__.py
+++ b/server/reflector/db/__init__.py
@@ -24,6 +24,7 @@ def get_database() -> databases.Database:
# import models
+import reflector.db.calendar_events # noqa
import reflector.db.meetings # noqa
import reflector.db.recordings # noqa
import reflector.db.rooms # noqa
diff --git a/server/reflector/db/calendar_events.py b/server/reflector/db/calendar_events.py
new file mode 100644
index 00000000..4a88d126
--- /dev/null
+++ b/server/reflector/db/calendar_events.py
@@ -0,0 +1,182 @@
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+import sqlalchemy as sa
+from pydantic import BaseModel, Field
+from sqlalchemy.dialects.postgresql import JSONB
+
+from reflector.db import get_database, metadata
+from reflector.utils import generate_uuid4
+
+calendar_events = sa.Table(
+ "calendar_event",
+ metadata,
+ sa.Column("id", sa.String, primary_key=True),
+ sa.Column(
+ "room_id",
+ sa.String,
+ sa.ForeignKey("room.id", ondelete="CASCADE", name="fk_calendar_event_room_id"),
+ nullable=False,
+ ),
+ sa.Column("ics_uid", sa.Text, nullable=False),
+ sa.Column("title", sa.Text),
+ sa.Column("description", sa.Text),
+ sa.Column("start_time", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("end_time", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("attendees", JSONB),
+ sa.Column("location", sa.Text),
+ sa.Column("ics_raw_data", sa.Text),
+ sa.Column("last_synced", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("is_deleted", sa.Boolean, nullable=False, server_default=sa.false()),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+ sa.UniqueConstraint("room_id", "ics_uid", name="uq_room_calendar_event"),
+ sa.Index("idx_calendar_event_room_start", "room_id", "start_time"),
+ sa.Index(
+ "idx_calendar_event_deleted",
+ "is_deleted",
+ postgresql_where=sa.text("NOT is_deleted"),
+ ),
+)
+
+
+class CalendarEvent(BaseModel):
+ id: str = Field(default_factory=generate_uuid4)
+ room_id: str
+ ics_uid: str
+ title: str | None = None
+ description: str | None = None
+ start_time: datetime
+ end_time: datetime
+ attendees: list[dict[str, Any]] | None = None
+ location: str | None = None
+ ics_raw_data: str | None = None
+ last_synced: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ is_deleted: bool = False
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+class CalendarEventController:
+ async def get_by_room(
+ self,
+ room_id: str,
+ include_deleted: bool = False,
+ start_after: datetime | None = None,
+ end_before: datetime | None = None,
+ ) -> list[CalendarEvent]:
+ query = calendar_events.select().where(calendar_events.c.room_id == room_id)
+
+ if not include_deleted:
+ query = query.where(calendar_events.c.is_deleted == False)
+
+ if start_after:
+ query = query.where(calendar_events.c.start_time >= start_after)
+
+ if end_before:
+ query = query.where(calendar_events.c.end_time <= end_before)
+
+ query = query.order_by(calendar_events.c.start_time.asc())
+
+ results = await get_database().fetch_all(query)
+ return [CalendarEvent(**result) for result in results]
+
+ async def get_upcoming(
+ self, room_id: str, minutes_ahead: int = 120
+ ) -> list[CalendarEvent]:
+ """Get upcoming events for a room within the specified minutes, including currently happening events."""
+ now = datetime.now(timezone.utc)
+ future_time = now + timedelta(minutes=minutes_ahead)
+
+ query = (
+ calendar_events.select()
+ .where(
+ sa.and_(
+ calendar_events.c.room_id == room_id,
+ calendar_events.c.is_deleted == False,
+ calendar_events.c.start_time <= future_time,
+ calendar_events.c.end_time >= now,
+ )
+ )
+ .order_by(calendar_events.c.start_time.asc())
+ )
+
+ results = await get_database().fetch_all(query)
+ return [CalendarEvent(**result) for result in results]
+
+ async def get_by_ics_uid(self, room_id: str, ics_uid: str) -> CalendarEvent | None:
+ query = calendar_events.select().where(
+ sa.and_(
+ calendar_events.c.room_id == room_id,
+ calendar_events.c.ics_uid == ics_uid,
+ )
+ )
+ result = await get_database().fetch_one(query)
+ return CalendarEvent(**result) if result else None
+
+ async def upsert(self, event: CalendarEvent) -> CalendarEvent:
+ existing = await self.get_by_ics_uid(event.room_id, event.ics_uid)
+
+ if existing:
+ event.id = existing.id
+ event.created_at = existing.created_at
+ event.updated_at = datetime.now(timezone.utc)
+
+ query = (
+ calendar_events.update()
+ .where(calendar_events.c.id == existing.id)
+ .values(**event.model_dump())
+ )
+ else:
+ query = calendar_events.insert().values(**event.model_dump())
+
+ await get_database().execute(query)
+ return event
+
+ async def soft_delete_missing(
+ self, room_id: str, current_ics_uids: list[str]
+ ) -> int:
+ """Soft delete future events that are no longer in the calendar."""
+ now = datetime.now(timezone.utc)
+
+ select_query = calendar_events.select().where(
+ sa.and_(
+ calendar_events.c.room_id == room_id,
+ calendar_events.c.start_time > now,
+ calendar_events.c.is_deleted == False,
+ calendar_events.c.ics_uid.notin_(current_ics_uids)
+ if current_ics_uids
+ else True,
+ )
+ )
+
+ to_delete = await get_database().fetch_all(select_query)
+ delete_count = len(to_delete)
+
+ if delete_count > 0:
+ update_query = (
+ calendar_events.update()
+ .where(
+ sa.and_(
+ calendar_events.c.room_id == room_id,
+ calendar_events.c.start_time > now,
+ calendar_events.c.is_deleted == False,
+ calendar_events.c.ics_uid.notin_(current_ics_uids)
+ if current_ics_uids
+ else True,
+ )
+ )
+ .values(is_deleted=True, updated_at=now)
+ )
+
+ await get_database().execute(update_query)
+
+ return delete_count
+
+ async def delete_by_room(self, room_id: str) -> int:
+ query = calendar_events.delete().where(calendar_events.c.room_id == room_id)
+ result = await get_database().execute(query)
+ return result.rowcount
+
+
+calendar_events_controller = CalendarEventController()
diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py
index c3821241..12a0c187 100644
--- a/server/reflector/db/meetings.py
+++ b/server/reflector/db/meetings.py
@@ -1,8 +1,9 @@
from datetime import datetime
-from typing import Literal
+from typing import Any, Literal
import sqlalchemy as sa
from pydantic import BaseModel, Field
+from sqlalchemy.dialects.postgresql import JSONB
from reflector.db import get_database, metadata
from reflector.db.rooms import Room
@@ -44,13 +45,18 @@ meetings = sa.Table(
nullable=False,
server_default=sa.true(),
),
- sa.Index("idx_meeting_room_id", "room_id"),
- sa.Index(
- "idx_one_active_meeting_per_room",
- "room_id",
- unique=True,
- postgresql_where=sa.text("is_active = true"),
+ sa.Column(
+ "calendar_event_id",
+ sa.String,
+ sa.ForeignKey(
+ "calendar_event.id",
+ ondelete="SET NULL",
+ name="fk_meeting_calendar_event_id",
+ ),
),
+ sa.Column("calendar_metadata", JSONB),
+ sa.Index("idx_meeting_room_id", "room_id"),
+ sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
)
meeting_consent = sa.Table(
@@ -92,6 +98,9 @@ class Meeting(BaseModel):
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
num_clients: int = 0
+ is_active: bool = True
+ calendar_event_id: str | None = None
+ calendar_metadata: dict[str, Any] | None = None
class MeetingController:
@@ -104,6 +113,8 @@ class MeetingController:
start_date: datetime,
end_date: datetime,
room: Room,
+ calendar_event_id: str | None = None,
+ calendar_metadata: dict[str, Any] | None = None,
):
meeting = Meeting(
id=id,
@@ -117,6 +128,8 @@ class MeetingController:
room_mode=room.room_mode,
recording_type=room.recording_type,
recording_trigger=room.recording_trigger,
+ calendar_event_id=calendar_event_id,
+ calendar_metadata=calendar_metadata,
)
query = meetings.insert().values(**meeting.model_dump())
await get_database().execute(query)
@@ -130,7 +143,16 @@ class MeetingController:
self,
room_name: str,
) -> Meeting | None:
- query = meetings.select().where(meetings.c.room_name == room_name)
+ """
+ Get a meeting by room name.
+ For backward compatibility, returns the most recent meeting.
+ """
+ end_date = getattr(meetings.c, "end_date")
+ query = (
+ meetings.select()
+ .where(meetings.c.room_name == room_name)
+ .order_by(end_date.desc())
+ )
result = await get_database().fetch_one(query)
if not result:
return None
@@ -138,6 +160,10 @@ class MeetingController:
return Meeting(**result)
async def get_active(self, room: Room, current_time: datetime) -> Meeting | None:
+ """
+ Get latest active meeting for a room.
+ For backward compatibility, returns the most recent active meeting.
+ """
end_date = getattr(meetings.c, "end_date")
query = (
meetings.select()
@@ -156,6 +182,43 @@ class MeetingController:
return Meeting(**result)
+ async def get_all_active_for_room(
+ self, room: Room, current_time: datetime
+ ) -> list[Meeting]:
+ end_date = getattr(meetings.c, "end_date")
+ query = (
+ meetings.select()
+ .where(
+ sa.and_(
+ meetings.c.room_id == room.id,
+ meetings.c.end_date > current_time,
+ meetings.c.is_active,
+ )
+ )
+ .order_by(end_date.desc())
+ )
+ results = await get_database().fetch_all(query)
+ return [Meeting(**result) for result in results]
+
+ async def get_active_by_calendar_event(
+ self, room: Room, calendar_event_id: str, current_time: datetime
+ ) -> Meeting | None:
+ """
+ Get active meeting for a specific calendar event.
+ """
+ query = meetings.select().where(
+ sa.and_(
+ meetings.c.room_id == room.id,
+ meetings.c.calendar_event_id == calendar_event_id,
+ meetings.c.end_date > current_time,
+ meetings.c.is_active,
+ )
+ )
+ result = await get_database().fetch_one(query)
+ if not result:
+ return None
+ return Meeting(**result)
+
async def get_by_id(self, meeting_id: str, **kwargs) -> Meeting | None:
query = meetings.select().where(meetings.c.id == meeting_id)
result = await get_database().fetch_one(query)
@@ -163,6 +226,15 @@ class MeetingController:
return None
return Meeting(**result)
+ async def get_by_calendar_event(self, calendar_event_id: str) -> Meeting | None:
+ query = meetings.select().where(
+ meetings.c.calendar_event_id == calendar_event_id
+ )
+ result = await get_database().fetch_one(query)
+ if not result:
+ return None
+ return Meeting(**result)
+
async def update_meeting(self, meeting_id: str, **kwargs):
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
await get_database().execute(query)
@@ -190,7 +262,6 @@ class MeetingConsentController:
return MeetingConsent(**result)
async def upsert(self, consent: MeetingConsent) -> MeetingConsent:
- """Create new consent or update existing one for authenticated users"""
if consent.user_id:
# For authenticated users, check if consent already exists
# not transactional but we're ok with that; the consents ain't deleted anyways
diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py
index abc45e61..396c818a 100644
--- a/server/reflector/db/rooms.py
+++ b/server/reflector/db/rooms.py
@@ -43,7 +43,15 @@ rooms = sqlalchemy.Table(
),
sqlalchemy.Column("webhook_url", sqlalchemy.String, nullable=True),
sqlalchemy.Column("webhook_secret", sqlalchemy.String, nullable=True),
+ sqlalchemy.Column("ics_url", sqlalchemy.Text),
+ sqlalchemy.Column("ics_fetch_interval", sqlalchemy.Integer, server_default="300"),
+ sqlalchemy.Column(
+ "ics_enabled", sqlalchemy.Boolean, nullable=False, server_default=false()
+ ),
+ sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)),
+ sqlalchemy.Column("ics_last_etag", sqlalchemy.Text),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
+ sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -64,6 +72,11 @@ class Room(BaseModel):
is_shared: bool = False
webhook_url: str | None = None
webhook_secret: str | None = None
+ ics_url: str | None = None
+ ics_fetch_interval: int = 300
+ ics_enabled: bool = False
+ ics_last_sync: datetime | None = None
+ ics_last_etag: str | None = None
class RoomController:
@@ -114,6 +127,9 @@ class RoomController:
is_shared: bool,
webhook_url: str = "",
webhook_secret: str = "",
+ ics_url: str | None = None,
+ ics_fetch_interval: int = 300,
+ ics_enabled: bool = False,
):
"""
Add a new room
@@ -134,6 +150,9 @@ class RoomController:
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,
)
query = rooms.insert().values(**room.model_dump())
try:
@@ -198,6 +217,13 @@ class RoomController:
return room
+ async def get_ics_enabled(self) -> list[Room]:
+ query = rooms.select().where(
+ rooms.c.ics_enabled == True, rooms.c.ics_url != None
+ )
+ results = await get_database().fetch_all(query)
+ return [Room(**result) for result in results]
+
async def remove_by_id(
self,
room_id: str,
diff --git a/server/reflector/redis_cache.py b/server/reflector/redis_cache.py
index 2215149e..cb7ac3b8 100644
--- a/server/reflector/redis_cache.py
+++ b/server/reflector/redis_cache.py
@@ -1,10 +1,17 @@
+import asyncio
import functools
import json
+from typing import Optional
import redis
+import redis.asyncio as redis_async
+import structlog
+from redis.exceptions import LockError
from reflector.settings import settings
+logger = structlog.get_logger(__name__)
+
redis_clients = {}
@@ -21,6 +28,12 @@ def get_redis_client(db=0):
return redis_clients[db]
+async def get_async_redis_client(db: int = 0):
+ return await redis_async.from_url(
+ f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}/{db}"
+ )
+
+
def redis_cache(prefix="cache", duration=3600, db=settings.REDIS_CACHE_DB, argidx=1):
"""
Cache the result of a function in Redis.
@@ -49,3 +62,87 @@ def redis_cache(prefix="cache", duration=3600, db=settings.REDIS_CACHE_DB, argid
return wrapper
return decorator
+
+
+class RedisAsyncLock:
+ def __init__(
+ self,
+ key: str,
+ timeout: int = 120,
+ extend_interval: int = 30,
+ skip_if_locked: bool = False,
+ blocking: bool = True,
+ blocking_timeout: Optional[float] = None,
+ ):
+ self.key = f"async_lock:{key}"
+ self.timeout = timeout
+ self.extend_interval = extend_interval
+ self.skip_if_locked = skip_if_locked
+ self.blocking = blocking
+ self.blocking_timeout = blocking_timeout
+ self._lock = None
+ self._redis = None
+ self._extend_task = None
+ self._acquired = False
+
+ async def _extend_lock_periodically(self):
+ while True:
+ try:
+ await asyncio.sleep(self.extend_interval)
+ if self._lock:
+ await self._lock.extend(self.timeout, replace_ttl=True)
+ logger.debug("Extended lock", key=self.key)
+ except LockError:
+ logger.warning("Failed to extend lock", key=self.key)
+ break
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error("Error extending lock", key=self.key, error=str(e))
+ break
+
+ async def __aenter__(self):
+ self._redis = await get_async_redis_client()
+ self._lock = self._redis.lock(
+ self.key,
+ timeout=self.timeout,
+ blocking=self.blocking,
+ blocking_timeout=self.blocking_timeout,
+ )
+
+ self._acquired = await self._lock.acquire()
+
+ if not self._acquired:
+ if self.skip_if_locked:
+ logger.warning(
+ "Lock already acquired by another process, skipping", key=self.key
+ )
+ return self
+ else:
+ raise LockError(f"Failed to acquire lock: {self.key}")
+
+ self._extend_task = asyncio.create_task(self._extend_lock_periodically())
+ logger.info("Acquired lock", key=self.key)
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ if self._extend_task:
+ self._extend_task.cancel()
+ try:
+ await self._extend_task
+ except asyncio.CancelledError:
+ pass
+
+ if self._acquired and self._lock:
+ try:
+ await self._lock.release()
+ logger.info("Released lock", key=self.key)
+ except LockError:
+ logger.debug("Lock already released or expired", key=self.key)
+
+ if self._redis:
+ await self._redis.aclose()
+
+ @property
+ def acquired(self) -> bool:
+ return self._acquired
diff --git a/server/reflector/services/ics_sync.py b/server/reflector/services/ics_sync.py
new file mode 100644
index 00000000..2a4855cb
--- /dev/null
+++ b/server/reflector/services/ics_sync.py
@@ -0,0 +1,408 @@
+"""
+ICS Calendar Synchronization Service
+
+This module provides services for fetching, parsing, and synchronizing ICS (iCalendar)
+calendar feeds with room booking data in the database.
+
+Key Components:
+- ICSFetchService: Handles HTTP fetching and parsing of ICS calendar data
+- ICSSyncService: Manages the synchronization process between ICS feeds and database
+
+Example Usage:
+ # Sync a room's calendar
+ room = Room(id="room1", name="conference-room", ics_url="https://cal.example.com/room.ics")
+ result = await ics_sync_service.sync_room_calendar(room)
+
+ # Result structure:
+ {
+ "status": "success", # success|unchanged|error|skipped
+ "hash": "abc123...", # MD5 hash of ICS content
+ "events_found": 5, # Events matching this room
+ "total_events": 12, # Total events in calendar within time window
+ "events_created": 2, # New events added to database
+ "events_updated": 3, # Existing events modified
+ "events_deleted": 1 # Events soft-deleted (no longer in calendar)
+ }
+
+Event Matching:
+ Events are matched to rooms by checking if the room's full URL appears in the
+ event's LOCATION or DESCRIPTION fields. Only events within a 25-hour window
+ (1 hour ago to 24 hours from now) are processed.
+
+Input: ICS calendar URL (e.g., "https://calendar.google.com/calendar/ical/...")
+Output: EventData objects with structured calendar information:
+ {
+ "ics_uid": "event123@google.com",
+ "title": "Team Meeting",
+ "description": "Weekly sync meeting",
+ "location": "https://meet.company.com/conference-room",
+ "start_time": datetime(2024, 1, 15, 14, 0, tzinfo=UTC),
+ "end_time": datetime(2024, 1, 15, 15, 0, tzinfo=UTC),
+ "attendees": [
+ {"email": "user@company.com", "name": "John Doe", "role": "ORGANIZER"},
+ {"email": "attendee@company.com", "name": "Jane Smith", "status": "ACCEPTED"}
+ ],
+ "ics_raw_data": "BEGIN:VEVENT\nUID:event123@google.com\n..."
+ }
+"""
+
+import hashlib
+from datetime import date, datetime, timedelta, timezone
+from enum import Enum
+from typing import TypedDict
+
+import httpx
+import pytz
+import structlog
+from icalendar import Calendar, Event
+
+from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
+from reflector.db.rooms import Room, rooms_controller
+from reflector.redis_cache import RedisAsyncLock
+from reflector.settings import settings
+
+logger = structlog.get_logger()
+
+EVENT_WINDOW_DELTA_START = timedelta(hours=-1)
+EVENT_WINDOW_DELTA_END = timedelta(hours=24)
+
+
+class SyncStatus(str, Enum):
+ SUCCESS = "success"
+ UNCHANGED = "unchanged"
+ ERROR = "error"
+ SKIPPED = "skipped"
+
+
+class AttendeeData(TypedDict, total=False):
+ email: str | None
+ name: str | None
+ status: str | None
+ role: str | None
+
+
+class EventData(TypedDict):
+ ics_uid: str
+ title: str | None
+ description: str | None
+ location: str | None
+ start_time: datetime
+ end_time: datetime
+ attendees: list[AttendeeData]
+ ics_raw_data: str
+
+
+class SyncStats(TypedDict):
+ events_created: int
+ events_updated: int
+ events_deleted: int
+
+
+class SyncResultBase(TypedDict):
+ status: SyncStatus
+
+
+class SyncResult(SyncResultBase, total=False):
+ hash: str | None
+ events_found: int
+ total_events: int
+ events_created: int
+ events_updated: int
+ events_deleted: int
+ error: str | None
+ reason: str | None
+
+
+class ICSFetchService:
+ def __init__(self):
+ self.client = httpx.AsyncClient(
+ timeout=30.0, headers={"User-Agent": "Reflector/1.0"}
+ )
+
+ async def fetch_ics(self, url: str) -> str:
+ response = await self.client.get(url)
+ response.raise_for_status()
+
+ return response.text
+
+ def parse_ics(self, ics_content: str) -> Calendar:
+ return Calendar.from_ical(ics_content)
+
+ def extract_room_events(
+ self, calendar: Calendar, room_name: str, room_url: str
+ ) -> tuple[list[EventData], int]:
+ events = []
+ total_events = 0
+ now = datetime.now(timezone.utc)
+ window_start = now + EVENT_WINDOW_DELTA_START
+ window_end = now + EVENT_WINDOW_DELTA_END
+
+ for component in calendar.walk():
+ if component.name != "VEVENT":
+ continue
+
+ status = component.get("STATUS", "").upper()
+ if status == "CANCELLED":
+ continue
+
+ # Count total non-cancelled events in the time window
+ event_data = self._parse_event(component)
+ if event_data and window_start <= event_data["start_time"] <= window_end:
+ total_events += 1
+
+ # Check if event matches this room
+ if self._event_matches_room(component, room_name, room_url):
+ events.append(event_data)
+
+ return events, total_events
+
+ def _event_matches_room(self, event: Event, room_name: str, room_url: str) -> bool:
+ location = str(event.get("LOCATION", ""))
+ description = str(event.get("DESCRIPTION", ""))
+
+ # Only match full room URL
+ # XXX leaved here as a patterns, to later be extended with tinyurl or such too
+ patterns = [
+ room_url,
+ ]
+
+ # Check location and description for patterns
+ text_to_check = f"{location} {description}".lower()
+ for pattern in patterns:
+ if pattern.lower() in text_to_check:
+ return True
+
+ return False
+
+ def _parse_event(self, event: Event) -> EventData | None:
+ uid = str(event.get("UID", ""))
+ summary = str(event.get("SUMMARY", ""))
+ description = str(event.get("DESCRIPTION", ""))
+ location = str(event.get("LOCATION", ""))
+ dtstart = event.get("DTSTART")
+ dtend = event.get("DTEND")
+
+ if not dtstart:
+ return None
+
+ # Convert fields
+ start_time = self._normalize_datetime(
+ dtstart.dt if hasattr(dtstart, "dt") else dtstart
+ )
+ end_time = (
+ self._normalize_datetime(dtend.dt if hasattr(dtend, "dt") else dtend)
+ if dtend
+ else start_time + timedelta(hours=1)
+ )
+ attendees = self._parse_attendees(event)
+
+ # Get raw event data for storage
+ raw_data = event.to_ical().decode("utf-8")
+
+ return {
+ "ics_uid": uid,
+ "title": summary,
+ "description": description,
+ "location": location,
+ "start_time": start_time,
+ "end_time": end_time,
+ "attendees": attendees,
+ "ics_raw_data": raw_data,
+ }
+
+ def _normalize_datetime(self, dt) -> datetime:
+ # Ensure datetime is with timezone, if not, assume UTC
+ if isinstance(dt, date) and not isinstance(dt, datetime):
+ dt = datetime.combine(dt, datetime.min.time())
+ dt = pytz.UTC.localize(dt)
+ elif isinstance(dt, datetime):
+ if dt.tzinfo is None:
+ dt = pytz.UTC.localize(dt)
+ else:
+ dt = dt.astimezone(pytz.UTC)
+
+ return dt
+
+ def _parse_attendees(self, event: Event) -> list[AttendeeData]:
+ # Extracts attendee information from both ATTENDEE and ORGANIZER properties.
+ # Handles malformed comma-separated email addresses in single ATTENDEE fields
+ # by splitting them into separate attendee entries. Returns a list of attendee
+ # data including email, name, status, and role information.
+ final_attendees = []
+
+ attendees = event.get("ATTENDEE", [])
+ if not isinstance(attendees, list):
+ attendees = [attendees]
+ for att in attendees:
+ email_str = str(att).replace("mailto:", "") if att else None
+
+ # Handle malformed comma-separated email addresses in a single ATTENDEE field
+ if email_str and "," in email_str:
+ # Split comma-separated emails and create separate attendee entries
+ email_parts = [email.strip() for email in email_str.split(",")]
+ for email in email_parts:
+ if email and "@" in email:
+ clean_email = email.replace("MAILTO:", "").replace(
+ "mailto:", ""
+ )
+ att_data: AttendeeData = {
+ "email": clean_email,
+ "name": att.params.get("CN")
+ if hasattr(att, "params") and email == email_parts[0]
+ else None,
+ "status": att.params.get("PARTSTAT")
+ if hasattr(att, "params") and email == email_parts[0]
+ else None,
+ "role": att.params.get("ROLE")
+ if hasattr(att, "params") and email == email_parts[0]
+ else None,
+ }
+ final_attendees.append(att_data)
+ else:
+ # Normal single attendee
+ att_data: AttendeeData = {
+ "email": email_str,
+ "name": att.params.get("CN") if hasattr(att, "params") else None,
+ "status": att.params.get("PARTSTAT")
+ if hasattr(att, "params")
+ else None,
+ "role": att.params.get("ROLE") if hasattr(att, "params") else None,
+ }
+ final_attendees.append(att_data)
+
+ # Add organizer
+ organizer = event.get("ORGANIZER")
+ if organizer:
+ org_email = (
+ str(organizer).replace("mailto:", "").replace("MAILTO:", "")
+ if organizer
+ else None
+ )
+ org_data: AttendeeData = {
+ "email": org_email,
+ "name": organizer.params.get("CN")
+ if hasattr(organizer, "params")
+ else None,
+ "role": "ORGANIZER",
+ }
+ final_attendees.append(org_data)
+
+ return final_attendees
+
+
+class ICSSyncService:
+ def __init__(self):
+ self.fetch_service = ICSFetchService()
+
+ async def sync_room_calendar(self, room: Room) -> SyncResult:
+ async with RedisAsyncLock(
+ f"ics_sync_room:{room.id}", skip_if_locked=True
+ ) as lock:
+ if not lock.acquired:
+ logger.warning("ICS sync already in progress for room", room_id=room.id)
+ return {
+ "status": SyncStatus.SKIPPED,
+ "reason": "Sync already in progress",
+ }
+
+ return await self._sync_room_calendar(room)
+
+ async def _sync_room_calendar(self, room: Room) -> SyncResult:
+ if not room.ics_enabled or not room.ics_url:
+ return {"status": SyncStatus.SKIPPED, "reason": "ICS not configured"}
+
+ try:
+ if not self._should_sync(room):
+ return {"status": SyncStatus.SKIPPED, "reason": "Not time to sync yet"}
+
+ ics_content = await self.fetch_service.fetch_ics(room.ics_url)
+ calendar = self.fetch_service.parse_ics(ics_content)
+
+ content_hash = hashlib.md5(ics_content.encode()).hexdigest()
+ if room.ics_last_etag == content_hash:
+ logger.info("No changes in ICS for room", room_id=room.id)
+ room_url = f"{settings.UI_BASE_URL}/{room.name}"
+ events, total_events = self.fetch_service.extract_room_events(
+ calendar, room.name, room_url
+ )
+ return {
+ "status": SyncStatus.UNCHANGED,
+ "hash": content_hash,
+ "events_found": len(events),
+ "total_events": total_events,
+ "events_created": 0,
+ "events_updated": 0,
+ "events_deleted": 0,
+ }
+
+ # Extract matching events
+ room_url = f"{settings.UI_BASE_URL}/{room.name}"
+ events, total_events = self.fetch_service.extract_room_events(
+ calendar, room.name, room_url
+ )
+ sync_result = await self._sync_events_to_database(room.id, events)
+
+ # Update room sync metadata
+ await rooms_controller.update(
+ room,
+ {
+ "ics_last_sync": datetime.now(timezone.utc),
+ "ics_last_etag": content_hash,
+ },
+ mutate=False,
+ )
+
+ return {
+ "status": SyncStatus.SUCCESS,
+ "hash": content_hash,
+ "events_found": len(events),
+ "total_events": total_events,
+ **sync_result,
+ }
+
+ except Exception as e:
+ logger.error("Failed to sync ICS for room", room_id=room.id, error=str(e))
+ return {"status": SyncStatus.ERROR, "error": str(e)}
+
+ def _should_sync(self, room: Room) -> bool:
+ if not room.ics_last_sync:
+ return True
+
+ time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync
+ return time_since_sync.total_seconds() >= room.ics_fetch_interval
+
+ async def _sync_events_to_database(
+ self, room_id: str, events: list[EventData]
+ ) -> SyncStats:
+ created = 0
+ updated = 0
+
+ current_ics_uids = []
+
+ for event_data in events:
+ calendar_event = CalendarEvent(room_id=room_id, **event_data)
+ existing = await calendar_events_controller.get_by_ics_uid(
+ room_id, event_data["ics_uid"]
+ )
+
+ if existing:
+ updated += 1
+ else:
+ created += 1
+
+ await calendar_events_controller.upsert(calendar_event)
+ current_ics_uids.append(event_data["ics_uid"])
+
+ # Soft delete events that are no longer in calendar
+ deleted = await calendar_events_controller.soft_delete_missing(
+ room_id, current_ics_uids
+ )
+
+ return {
+ "events_created": created,
+ "events_updated": updated,
+ "events_deleted": deleted,
+ }
+
+
+ics_sync_service = ICSSyncService()
diff --git a/server/reflector/views/meetings.py b/server/reflector/views/meetings.py
index 2603d875..25987e47 100644
--- a/server/reflector/views/meetings.py
+++ b/server/reflector/views/meetings.py
@@ -10,6 +10,7 @@ from reflector.db.meetings import (
meeting_consent_controller,
meetings_controller,
)
+from reflector.db.rooms import rooms_controller
router = APIRouter()
@@ -41,3 +42,34 @@ async def meeting_audio_consent(
updated_consent = await meeting_consent_controller.upsert(consent)
return {"status": "success", "consent_id": updated_consent.id}
+
+
+@router.patch("/meetings/{meeting_id}/deactivate")
+async def meeting_deactivate(
+ meeting_id: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user)],
+):
+ user_id = user["sub"] if user else None
+ if not user_id:
+ raise HTTPException(status_code=401, detail="Authentication required")
+
+ meeting = await meetings_controller.get_by_id(meeting_id)
+ if not meeting:
+ raise HTTPException(status_code=404, detail="Meeting not found")
+
+ if not meeting.is_active:
+ return {"status": "success", "meeting_id": meeting_id}
+
+ # Only room owner or meeting creator can deactivate
+ room = await rooms_controller.get_by_id(meeting.room_id)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ if user_id != room.user_id and user_id != meeting.user_id:
+ raise HTTPException(
+ status_code=403, detail="Only the room owner can deactivate meetings"
+ )
+
+ await meetings_controller.update_meeting(meeting_id, is_active=False)
+
+ return {"status": "success", "meeting_id": meeting_id}
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index 546c1dd3..b849ae3d 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -1,34 +1,27 @@
import logging
-import sqlite3
from datetime import datetime, timedelta, timezone
-from typing import Annotated, Literal, Optional
+from enum import Enum
+from typing import Annotated, Any, Literal, Optional
-import asyncpg.exceptions
from fastapi import APIRouter, Depends, HTTPException
from fastapi_pagination import Page
from fastapi_pagination.ext.databases import apaginate
from pydantic import BaseModel
+from redis.exceptions import LockError
import reflector.auth as auth
from reflector.db import get_database
+from reflector.db.calendar_events import calendar_events_controller
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
+from reflector.redis_cache import RedisAsyncLock
+from reflector.services.ics_sync import ics_sync_service
from reflector.settings import settings
from reflector.whereby import create_meeting, upload_logo
from reflector.worker.webhook import test_webhook
logger = logging.getLogger(__name__)
-router = APIRouter()
-
-
-def parse_datetime_with_timezone(iso_string: str) -> datetime:
- """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive)."""
- dt = datetime.fromisoformat(iso_string)
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt
-
class Room(BaseModel):
id: str
@@ -43,6 +36,11 @@ class Room(BaseModel):
recording_type: str
recording_trigger: str
is_shared: bool
+ ics_url: Optional[str] = None
+ ics_fetch_interval: int = 300
+ ics_enabled: bool = False
+ ics_last_sync: Optional[datetime] = None
+ ics_last_etag: Optional[str] = None
class RoomDetails(Room):
@@ -54,10 +52,22 @@ class Meeting(BaseModel):
id: str
room_name: str
room_url: str
+ # TODO it's not always present, | None
host_room_url: str
start_date: datetime
end_date: datetime
+ user_id: str | None = None
+ room_id: str | None = None
+ is_locked: bool = False
+ room_mode: Literal["normal", "group"] = "normal"
recording_type: Literal["none", "local", "cloud"] = "cloud"
+ recording_trigger: Literal[
+ "none", "prompt", "automatic", "automatic-2nd-participant"
+ ] = "automatic-2nd-participant"
+ num_clients: int = 0
+ is_active: bool = True
+ calendar_event_id: str | None = None
+ calendar_metadata: dict[str, Any] | None = None
class CreateRoom(BaseModel):
@@ -72,20 +82,30 @@ class CreateRoom(BaseModel):
is_shared: bool
webhook_url: str
webhook_secret: str
+ ics_url: Optional[str] = None
+ ics_fetch_interval: int = 300
+ ics_enabled: bool = False
class UpdateRoom(BaseModel):
- name: str
- zulip_auto_post: bool
- zulip_stream: str
- zulip_topic: str
- is_locked: bool
- room_mode: str
- recording_type: str
- recording_trigger: str
- is_shared: bool
- webhook_url: str
- webhook_secret: str
+ name: Optional[str] = None
+ zulip_auto_post: Optional[bool] = None
+ zulip_stream: Optional[str] = None
+ zulip_topic: Optional[str] = None
+ is_locked: Optional[bool] = None
+ room_mode: Optional[str] = None
+ recording_type: Optional[str] = None
+ recording_trigger: Optional[str] = None
+ is_shared: Optional[bool] = None
+ webhook_url: Optional[str] = None
+ webhook_secret: Optional[str] = None
+ ics_url: Optional[str] = None
+ ics_fetch_interval: Optional[int] = None
+ ics_enabled: Optional[bool] = None
+
+
+class CreateRoomMeeting(BaseModel):
+ allow_duplicated: Optional[bool] = False
class DeletionStatus(BaseModel):
@@ -100,6 +120,59 @@ class WebhookTestResult(BaseModel):
response_preview: str | None = None
+class ICSStatus(BaseModel):
+ status: Literal["enabled", "disabled"]
+ last_sync: Optional[datetime] = None
+ next_sync: Optional[datetime] = None
+ last_etag: Optional[str] = None
+ events_count: int = 0
+
+
+class SyncStatus(str, Enum):
+ success = "success"
+ unchanged = "unchanged"
+ error = "error"
+ skipped = "skipped"
+
+
+class ICSSyncResult(BaseModel):
+ status: SyncStatus
+ hash: Optional[str] = None
+ events_found: int = 0
+ total_events: int = 0
+ events_created: int = 0
+ events_updated: int = 0
+ events_deleted: int = 0
+ error: Optional[str] = None
+ reason: Optional[str] = None
+
+
+class CalendarEventResponse(BaseModel):
+ id: str
+ room_id: str
+ ics_uid: str
+ title: Optional[str] = None
+ description: Optional[str] = None
+ start_time: datetime
+ end_time: datetime
+ attendees: Optional[list[dict]] = None
+ location: Optional[str] = None
+ last_synced: datetime
+ created_at: datetime
+ updated_at: datetime
+
+
+router = APIRouter()
+
+
+def parse_datetime_with_timezone(iso_string: str) -> datetime:
+ """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive)."""
+ dt = datetime.fromisoformat(iso_string)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt
+
+
@router.get("/rooms", response_model=Page[RoomDetails])
async def rooms_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
@@ -129,6 +202,30 @@ async def rooms_get(
return room
+@router.get("/rooms/name/{room_name}", response_model=RoomDetails)
+async def rooms_get_by_name(
+ room_name: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ user_id = user["sub"] if user else None
+ room = await rooms_controller.get_by_name(room_name)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ # Convert to RoomDetails format (add webhook fields if user is owner)
+ room_dict = room.__dict__.copy()
+ if user_id == room.user_id:
+ # User is owner, include webhook details if available
+ room_dict["webhook_url"] = getattr(room, "webhook_url", None)
+ room_dict["webhook_secret"] = getattr(room, "webhook_secret", None)
+ else:
+ # Non-owner, hide webhook details
+ room_dict["webhook_url"] = None
+ room_dict["webhook_secret"] = None
+
+ return RoomDetails(**room_dict)
+
+
@router.post("/rooms", response_model=Room)
async def rooms_create(
room: CreateRoom,
@@ -149,6 +246,9 @@ async def rooms_create(
is_shared=room.is_shared,
webhook_url=room.webhook_url,
webhook_secret=room.webhook_secret,
+ ics_url=room.ics_url,
+ ics_fetch_interval=room.ics_fetch_interval,
+ ics_enabled=room.ics_enabled,
)
@@ -183,6 +283,7 @@ async def rooms_delete(
@router.post("/rooms/{room_name}/meeting", response_model=Meeting)
async def rooms_create_meeting(
room_name: str,
+ info: CreateRoomMeeting,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
user_id = user["sub"] if user else None
@@ -190,50 +291,44 @@ async def rooms_create_meeting(
if not room:
raise HTTPException(status_code=404, detail="Room not found")
- current_time = datetime.now(timezone.utc)
- meeting = await meetings_controller.get_active(room=room, current_time=current_time)
+ try:
+ async with RedisAsyncLock(
+ f"create_meeting:{room_name}",
+ timeout=30,
+ extend_interval=10,
+ blocking_timeout=5.0,
+ ) as lock:
+ current_time = datetime.now(timezone.utc)
- if meeting is None:
- end_date = current_time + timedelta(hours=8)
+ meeting = None
+ if not info.allow_duplicated:
+ meeting = await meetings_controller.get_active(
+ room=room, current_time=current_time
+ )
- whereby_meeting = await create_meeting("", end_date=end_date, room=room)
-
- await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
-
- # Now try to save to database
- try:
- meeting = await meetings_controller.create(
- id=whereby_meeting["meetingId"],
- room_name=whereby_meeting["roomName"],
- room_url=whereby_meeting["roomUrl"],
- host_room_url=whereby_meeting["hostRoomUrl"],
- start_date=parse_datetime_with_timezone(whereby_meeting["startDate"]),
- end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]),
- room=room,
- )
- except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError):
- # Another request already created a meeting for this room
- # Log this race condition occurrence
- logger.warning(
- "Race condition detected for room %s and meeting %s - fetching existing meeting",
- room.name,
- whereby_meeting["meetingId"],
- )
-
- # Fetch the meeting that was created by the other request
- meeting = await meetings_controller.get_active(
- room=room, current_time=current_time
- )
if meeting is None:
- # Edge case: meeting was created but expired/deleted between checks
- logger.error(
- "Meeting disappeared after race condition for room %s",
- room.name,
- exc_info=True,
- )
- raise HTTPException(
- status_code=503, detail="Unable to join meeting - please try again"
+ end_date = current_time + timedelta(hours=8)
+
+ whereby_meeting = await create_meeting("", end_date=end_date, room=room)
+
+ await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
+
+ meeting = await meetings_controller.create(
+ id=whereby_meeting["meetingId"],
+ room_name=whereby_meeting["roomName"],
+ room_url=whereby_meeting["roomUrl"],
+ host_room_url=whereby_meeting["hostRoomUrl"],
+ start_date=parse_datetime_with_timezone(
+ whereby_meeting["startDate"]
+ ),
+ end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]),
+ room=room,
)
+ except LockError:
+ logger.warning("Failed to acquire lock for room %s within timeout", room_name)
+ raise HTTPException(
+ status_code=503, detail="Meeting creation in progress, please try again"
+ )
if user_id != room.user_id:
meeting.host_room_url = ""
@@ -260,3 +355,202 @@ async def rooms_test_webhook(
result = await test_webhook(room_id)
return WebhookTestResult(**result)
+
+
+@router.post("/rooms/{room_name}/ics/sync", response_model=ICSSyncResult)
+async def rooms_sync_ics(
+ room_name: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ user_id = user["sub"] if user else None
+ room = await rooms_controller.get_by_name(room_name)
+
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ if user_id != room.user_id:
+ raise HTTPException(
+ status_code=403, detail="Only room owner can trigger ICS sync"
+ )
+
+ if not room.ics_enabled or not room.ics_url:
+ raise HTTPException(status_code=400, detail="ICS not configured for this room")
+
+ result = await ics_sync_service.sync_room_calendar(room)
+
+ if result["status"] == "error":
+ raise HTTPException(
+ status_code=500, detail=result.get("error", "Unknown error")
+ )
+
+ return ICSSyncResult(**result)
+
+
+@router.get("/rooms/{room_name}/ics/status", response_model=ICSStatus)
+async def rooms_ics_status(
+ room_name: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ user_id = user["sub"] if user else None
+ room = await rooms_controller.get_by_name(room_name)
+
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ if user_id != room.user_id:
+ raise HTTPException(
+ status_code=403, detail="Only room owner can view ICS status"
+ )
+
+ next_sync = None
+ if room.ics_enabled and room.ics_last_sync:
+ next_sync = room.ics_last_sync + timedelta(seconds=room.ics_fetch_interval)
+
+ events = await calendar_events_controller.get_by_room(
+ room.id, include_deleted=False
+ )
+
+ return ICSStatus(
+ status="enabled" if room.ics_enabled else "disabled",
+ last_sync=room.ics_last_sync,
+ next_sync=next_sync,
+ last_etag=room.ics_last_etag,
+ events_count=len(events),
+ )
+
+
+@router.get("/rooms/{room_name}/meetings", response_model=list[CalendarEventResponse])
+async def rooms_list_meetings(
+ room_name: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ user_id = user["sub"] if user else None
+ room = await rooms_controller.get_by_name(room_name)
+
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ events = await calendar_events_controller.get_by_room(
+ room.id, include_deleted=False
+ )
+
+ if user_id != room.user_id:
+ for event in events:
+ event.description = None
+ event.attendees = None
+
+ return events
+
+
+@router.get(
+ "/rooms/{room_name}/meetings/upcoming", response_model=list[CalendarEventResponse]
+)
+async def rooms_list_upcoming_meetings(
+ room_name: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ minutes_ahead: int = 120,
+):
+ user_id = user["sub"] if user else None
+ room = await rooms_controller.get_by_name(room_name)
+
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ events = await calendar_events_controller.get_upcoming(
+ room.id, minutes_ahead=minutes_ahead
+ )
+
+ if user_id != room.user_id:
+ for event in events:
+ event.description = None
+ event.attendees = None
+
+ return events
+
+
+@router.get("/rooms/{room_name}/meetings/active", response_model=list[Meeting])
+async def rooms_list_active_meetings(
+ room_name: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ user_id = user["sub"] if user else None
+ room = await rooms_controller.get_by_name(room_name)
+
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ current_time = datetime.now(timezone.utc)
+ meetings = await meetings_controller.get_all_active_for_room(
+ room=room, current_time=current_time
+ )
+
+ # Hide host URLs from non-owners
+ if user_id != room.user_id:
+ for meeting in meetings:
+ meeting.host_room_url = ""
+
+ return meetings
+
+
+@router.get("/rooms/{room_name}/meetings/{meeting_id}", response_model=Meeting)
+async def rooms_get_meeting(
+ room_name: str,
+ meeting_id: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ """Get a single meeting by ID within a specific room."""
+ user_id = user["sub"] if user else None
+
+ room = await rooms_controller.get_by_name(room_name)
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ meeting = await meetings_controller.get_by_id(meeting_id)
+ if not meeting:
+ raise HTTPException(status_code=404, detail="Meeting not found")
+
+ if meeting.room_id != room.id:
+ raise HTTPException(
+ status_code=403, detail="Meeting does not belong to this room"
+ )
+
+ if user_id != room.user_id and not room.is_shared:
+ meeting.host_room_url = ""
+
+ return meeting
+
+
+@router.post("/rooms/{room_name}/meetings/{meeting_id}/join", response_model=Meeting)
+async def rooms_join_meeting(
+ room_name: str,
+ meeting_id: str,
+ user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+):
+ user_id = user["sub"] if user else None
+ room = await rooms_controller.get_by_name(room_name)
+
+ if not room:
+ raise HTTPException(status_code=404, detail="Room not found")
+
+ meeting = await meetings_controller.get_by_id(meeting_id)
+
+ if not meeting:
+ raise HTTPException(status_code=404, detail="Meeting not found")
+
+ if meeting.room_id != room.id:
+ raise HTTPException(
+ status_code=403, detail="Meeting does not belong to this room"
+ )
+
+ if not meeting.is_active:
+ raise HTTPException(status_code=400, detail="Meeting is not active")
+
+ current_time = datetime.now(timezone.utc)
+ if meeting.end_date <= current_time:
+ raise HTTPException(status_code=400, detail="Meeting has ended")
+
+ # Hide host URL from non-owners
+ if user_id != room.user_id:
+ meeting.host_room_url = ""
+
+ return meeting
diff --git a/server/reflector/views/whereby.py b/server/reflector/views/whereby.py
index c1682621..d12b0a9f 100644
--- a/server/reflector/views/whereby.py
+++ b/server/reflector/views/whereby.py
@@ -68,8 +68,7 @@ async def whereby_webhook(event: WherebyWebhookEvent, request: Request):
raise HTTPException(status_code=404, detail="Meeting not found")
if event.type in ["room.client.joined", "room.client.left"]:
- await meetings_controller.update_meeting(
- meeting.id, num_clients=event.data["numClients"]
- )
+ update_data = {"num_clients": event.data["numClients"]}
+ await meetings_controller.update_meeting(meeting.id, **update_data)
return {"status": "ok"}
diff --git a/server/reflector/worker/app.py b/server/reflector/worker/app.py
index e9468bd2..3c7795a2 100644
--- a/server/reflector/worker/app.py
+++ b/server/reflector/worker/app.py
@@ -20,6 +20,7 @@ else:
"reflector.worker.healthcheck",
"reflector.worker.process",
"reflector.worker.cleanup",
+ "reflector.worker.ics_sync",
]
)
@@ -37,6 +38,14 @@ else:
"task": "reflector.worker.process.reprocess_failed_recordings",
"schedule": crontab(hour=5, minute=0), # Midnight EST
},
+ "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
+ },
+ "create_upcoming_meetings": {
+ "task": "reflector.worker.ics_sync.create_upcoming_meetings",
+ "schedule": 30.0, # Run every 30 seconds to create upcoming meetings
+ },
}
if settings.PUBLIC_MODE:
diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py
new file mode 100644
index 00000000..faf62f4a
--- /dev/null
+++ b/server/reflector/worker/ics_sync.py
@@ -0,0 +1,175 @@
+from datetime import datetime, timedelta, timezone
+
+import structlog
+from celery import shared_task
+from celery.utils.log import get_task_logger
+
+from reflector.asynctask import asynctask
+from reflector.db.calendar_events import calendar_events_controller
+from reflector.db.meetings import meetings_controller
+from reflector.db.rooms import rooms_controller
+from reflector.redis_cache import RedisAsyncLock
+from reflector.services.ics_sync import SyncStatus, ics_sync_service
+from reflector.whereby import create_meeting, upload_logo
+
+logger = structlog.wrap_logger(get_task_logger(__name__))
+
+
+@shared_task
+@asynctask
+async def sync_room_ics(room_id: str):
+ try:
+ room = await rooms_controller.get_by_id(room_id)
+ if not room:
+ logger.warning("Room not found for ICS sync", room_id=room_id)
+ return
+
+ if not room.ics_enabled or not room.ics_url:
+ logger.debug("ICS not enabled for room", room_id=room_id)
+ return
+
+ logger.info("Starting ICS sync for room", room_id=room_id, room_name=room.name)
+ result = await ics_sync_service.sync_room_calendar(room)
+
+ if result["status"] == SyncStatus.SUCCESS:
+ logger.info(
+ "ICS sync completed successfully",
+ room_id=room_id,
+ events_found=result.get("events_found", 0),
+ events_created=result.get("events_created", 0),
+ events_updated=result.get("events_updated", 0),
+ events_deleted=result.get("events_deleted", 0),
+ )
+ elif result["status"] == SyncStatus.UNCHANGED:
+ logger.debug("ICS content unchanged", room_id=room_id)
+ elif result["status"] == SyncStatus.ERROR:
+ logger.error("ICS sync failed", room_id=room_id, error=result.get("error"))
+ else:
+ logger.debug(
+ "ICS sync skipped", room_id=room_id, reason=result.get("reason")
+ )
+
+ except Exception as e:
+ logger.error("Unexpected error during ICS sync", room_id=room_id, error=str(e))
+
+
+@shared_task
+@asynctask
+async def sync_all_ics_calendars():
+ try:
+ logger.info("Starting sync for all ICS-enabled rooms")
+
+ ics_enabled_rooms = await rooms_controller.get_ics_enabled()
+ logger.info(f"Found {len(ics_enabled_rooms)} rooms with ICS enabled")
+
+ for room in ics_enabled_rooms:
+ if not _should_sync(room):
+ logger.debug("Skipping room, not time to sync yet", room_id=room.id)
+ continue
+
+ sync_room_ics.delay(room.id)
+
+ logger.info("Queued sync tasks for all eligible rooms")
+
+ except Exception as e:
+ logger.error("Error in sync_all_ics_calendars", error=str(e))
+
+
+def _should_sync(room) -> bool:
+ if not room.ics_last_sync:
+ return True
+
+ time_since_sync = datetime.now(timezone.utc) - room.ics_last_sync
+ return time_since_sync.total_seconds() >= room.ics_fetch_interval
+
+
+MEETING_DEFAULT_DURATION = timedelta(hours=1)
+
+
+async def create_upcoming_meetings_for_event(event, create_window, room_id, room):
+ if event.start_time <= create_window:
+ return
+ existing_meeting = await meetings_controller.get_by_calendar_event(event.id)
+
+ if existing_meeting:
+ return
+
+ logger.info(
+ "Pre-creating meeting for calendar event",
+ room_id=room_id,
+ event_id=event.id,
+ event_title=event.title,
+ )
+
+ try:
+ end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION)
+
+ whereby_meeting = await create_meeting(
+ "",
+ end_date=end_date,
+ room=room,
+ )
+ await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
+
+ meeting = await meetings_controller.create(
+ id=whereby_meeting["meetingId"],
+ room_name=whereby_meeting["roomName"],
+ room_url=whereby_meeting["roomUrl"],
+ host_room_url=whereby_meeting["hostRoomUrl"],
+ start_date=datetime.fromisoformat(whereby_meeting["startDate"]),
+ end_date=datetime.fromisoformat(whereby_meeting["endDate"]),
+ room=room,
+ calendar_event_id=event.id,
+ calendar_metadata={
+ "title": event.title,
+ "description": event.description,
+ "attendees": event.attendees,
+ },
+ )
+
+ logger.info(
+ "Meeting pre-created successfully",
+ meeting_id=meeting.id,
+ event_id=event.id,
+ )
+
+ except Exception as e:
+ logger.error(
+ "Failed to pre-create meeting",
+ room_id=room_id,
+ event_id=event.id,
+ error=str(e),
+ )
+
+
+@shared_task
+@asynctask
+async def create_upcoming_meetings():
+ async with RedisAsyncLock("create_upcoming_meetings", skip_if_locked=True) as lock:
+ if not lock.acquired:
+ logger.warning(
+ "Another worker is already creating upcoming meetings, skipping"
+ )
+ return
+
+ try:
+ logger.info("Starting creation of upcoming meetings")
+
+ ics_enabled_rooms = await rooms_controller.get_ics_enabled()
+ now = datetime.now(timezone.utc)
+ create_window = now - timedelta(minutes=6)
+
+ for room in ics_enabled_rooms:
+ events = await calendar_events_controller.get_upcoming(
+ room.id,
+ minutes_ahead=7,
+ )
+
+ for event in events:
+ await create_upcoming_meetings_for_event(
+ event, create_window, room.id, room
+ )
+ logger.info("Completed pre-creation check for upcoming meetings")
+
+ except Exception as e:
+ logger.error("Error in create_upcoming_meetings", error=str(e))
diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py
index 00126514..8c885d14 100644
--- a/server/reflector/worker/process.py
+++ b/server/reflector/worker/process.py
@@ -9,6 +9,7 @@ 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.db.meetings import meetings_controller
from reflector.db.recordings import Recording, recordings_controller
@@ -16,6 +17,7 @@ from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
from reflector.pipelines.main_live_pipeline import asynctask
+from reflector.redis_cache import get_redis_client
from reflector.settings import settings
from reflector.whereby import get_room_sessions
@@ -147,24 +149,94 @@ async def process_recording(bucket_name: str, object_key: str):
@shared_task
@asynctask
async def process_meetings():
+ """
+ Checks which meetings are still active and deactivates those that have ended.
+
+ Deactivation logic:
+ - Active sessions: Keep meeting active regardless of scheduled time
+ - No active sessions:
+ * Calendar meetings:
+ - If previously used (had sessions): Deactivate immediately
+ - If never used: Keep active until scheduled end time, then deactivate
+ * On-the-fly meetings: Deactivate immediately (created when someone joins,
+ so no sessions means everyone left)
+
+ Uses distributed locking to prevent race conditions when multiple workers
+ process the same meeting simultaneously.
+ """
logger.info("Processing meetings")
meetings = await meetings_controller.get_all_active()
+ current_time = datetime.now(timezone.utc)
+ redis_client = get_redis_client()
+ processed_count = 0
+ skipped_count = 0
+
for meeting in meetings:
- is_active = False
- end_date = meeting.end_date
- if end_date.tzinfo is None:
- end_date = end_date.replace(tzinfo=timezone.utc)
- if end_date > datetime.now(timezone.utc):
+ logger_ = logger.bind(meeting_id=meeting.id, room_name=meeting.room_name)
+ 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
+
+ # 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)
+
+ # This API call could be slow, extend lock if needed
response = await get_room_sessions(meeting.room_name)
+
+ try:
+ # Extend lock after slow operation to ensure we still hold it
+ lock.extend(120, replace_ttl=True)
+ except LockError:
+ logger_.warning("Lost lock for meeting, skipping")
+ continue
+
room_sessions = response.get("results", [])
- is_active = not room_sessions or any(
+ has_active_sessions = room_sessions and any(
rs["endedAt"] is None for rs in room_sessions
)
- if not is_active:
- await meetings_controller.update_meeting(meeting.id, is_active=False)
- logger.info("Meeting %s is deactivated", meeting.id)
+ has_had_sessions = bool(room_sessions)
- logger.info("Processed meetings")
+ 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",
+ meeting.id,
+ )
+ 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")
+
+ processed_count += 1
+
+ except Exception as e:
+ logger_.error(f"Error processing meeting", exc_info=True)
+ finally:
+ try:
+ lock.release()
+ except LockError:
+ pass # Lock already released or expired
+
+ logger.info(
+ f"Processed meetings finished",
+ processed_count=processed_count,
+ skipped_count=skipped_count,
+ )
@shared_task
diff --git a/server/test.ics b/server/test.ics
new file mode 100644
index 00000000..8d0b6653
--- /dev/null
+++ b/server/test.ics
@@ -0,0 +1,29 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+PRODID:-//Fastmail/2020.5/EN
+X-APPLE-CALENDAR-COLOR:#0F6A0F
+X-WR-CALNAME:Test reflector
+X-WR-TIMEZONE:America/Costa_Rica
+BEGIN:VTIMEZONE
+TZID:America/Costa_Rica
+BEGIN:STANDARD
+DTSTART:19700101T000000
+TZOFFSETFROM:-0600
+TZOFFSETTO:-0600
+END:STANDARD
+END:VTIMEZONE
+BEGIN:VEVENT
+ATTENDEE;CN=Mathieu Virbel;PARTSTAT=ACCEPTED:MAILTO:mathieu@monadical.com
+DTEND;TZID=America/Costa_Rica:20250819T143000
+DTSTAMP:20250819T155951Z
+DTSTART;TZID=America/Costa_Rica:20250819T140000
+LOCATION:http://localhost:1250/mathieu
+ORGANIZER;CN=Mathieu Virbel:MAILTO:mathieu@monadical.com
+SEQUENCE:1
+SUMMARY:Checkin
+TRANSP:OPAQUE
+UID:867df50d-8105-4c58-9280-2b5d26cc9cd3
+END:VEVENT
+END:VCALENDAR
diff --git a/server/tests/test_attendee_parsing_bug.ics b/server/tests/test_attendee_parsing_bug.ics
new file mode 100644
index 00000000..1adc99fe
--- /dev/null
+++ b/server/tests/test_attendee_parsing_bug.ics
@@ -0,0 +1,18 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+PRODID:-//Test/1.0/EN
+X-WR-CALNAME:Test Attendee Bug
+BEGIN:VEVENT
+ATTENDEE:MAILTO:alice@example.com,bob@example.com,charlie@example.com,diana@example.com,eve@example.com,frank@example.com,george@example.com,helen@example.com,ivan@example.com,jane@example.com,kevin@example.com,laura@example.com,mike@example.com,nina@example.com,oscar@example.com,paul@example.com,queen@example.com,robert@example.com,sarah@example.com,tom@example.com,ursula@example.com,victor@example.com,wendy@example.com,xavier@example.com,yvonne@example.com,zack@example.com,amy@example.com,bill@example.com,carol@example.com
+DTEND:20250910T190000Z
+DTSTAMP:20250910T174000Z
+DTSTART:20250910T180000Z
+LOCATION:http://localhost:3000/test-room
+ORGANIZER;CN=Test Organizer:MAILTO:organizer@example.com
+SEQUENCE:1
+SUMMARY:Test Meeting with Many Attendees
+UID:test-attendee-bug-event
+END:VEVENT
+END:VCALENDAR
diff --git a/server/tests/test_attendee_parsing_bug.py b/server/tests/test_attendee_parsing_bug.py
new file mode 100644
index 00000000..5e038761
--- /dev/null
+++ b/server/tests/test_attendee_parsing_bug.py
@@ -0,0 +1,192 @@
+import os
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from reflector.db.rooms import rooms_controller
+from reflector.services.ics_sync import ICSSyncService
+
+
+@pytest.mark.asyncio
+async def test_attendee_parsing_bug():
+ """
+ Test that reproduces the attendee parsing bug where a string with comma-separated
+ emails gets parsed as individual characters instead of separate email addresses.
+
+ The bug manifests as getting 29 attendees with emails like "M", "A", "I", etc.
+ instead of properly parsed email addresses.
+ """
+ # Create a test room
+ room = await rooms_controller.add(
+ name="test-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="http://test.com/test.ics",
+ ics_enabled=True,
+ )
+
+ # Read the test ICS file that reproduces the bug and update it with current time
+ from datetime import datetime, timedelta, timezone
+
+ test_ics_path = os.path.join(
+ os.path.dirname(__file__), "test_attendee_parsing_bug.ics"
+ )
+ with open(test_ics_path, "r") as f:
+ ics_content = f.read()
+
+ # Replace the dates with current time + 1 hour to ensure it's within the 24h window
+ now = datetime.now(timezone.utc)
+ future_time = now + timedelta(hours=1)
+ end_time = future_time + timedelta(hours=1)
+
+ # Format dates for ICS format
+ dtstart = future_time.strftime("%Y%m%dT%H%M%SZ")
+ dtend = end_time.strftime("%Y%m%dT%H%M%SZ")
+ dtstamp = now.strftime("%Y%m%dT%H%M%SZ")
+
+ # Update the ICS content with current dates
+ ics_content = ics_content.replace("20250910T180000Z", dtstart)
+ ics_content = ics_content.replace("20250910T190000Z", dtend)
+ ics_content = ics_content.replace("20250910T174000Z", dtstamp)
+
+ # Create sync service and mock the fetch
+ sync_service = ICSSyncService()
+
+ with patch.object(
+ sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock
+ ) as mock_fetch:
+ mock_fetch.return_value = ics_content
+
+ # Debug: Parse the ICS content directly to examine attendee parsing
+ calendar = sync_service.fetch_service.parse_ics(ics_content)
+ from reflector.settings import settings
+
+ room_url = f"{settings.UI_BASE_URL}/{room.name}"
+
+ print(f"Room URL being used for matching: {room_url}")
+ print(f"ICS content:\n{ics_content}")
+
+ events, total_events = sync_service.fetch_service.extract_room_events(
+ calendar, room.name, room_url
+ )
+
+ print(f"Total events in calendar: {total_events}")
+ print(f"Events matching room: {len(events)}")
+
+ # Perform the sync
+ result = await sync_service.sync_room_calendar(room)
+
+ # Check that the sync succeeded
+ assert result.get("status") == "success"
+ assert result.get("events_found", 0) >= 0 # Allow for debugging
+
+ # We already have the matching events from the debug code above
+ assert len(events) == 1
+ event = events[0]
+
+ # This is where the bug manifests - check the attendees
+ attendees = event["attendees"]
+
+ # Print attendee info for debugging
+ print(f"Number of attendees found: {len(attendees)}")
+ for i, attendee in enumerate(attendees):
+ print(
+ f"Attendee {i}: email='{attendee.get('email')}', name='{attendee.get('name')}'"
+ )
+
+ # With the fix, we should now get properly parsed email addresses
+ # Check that no single characters are parsed as emails
+ single_char_emails = [
+ att for att in attendees if att.get("email") and len(att["email"]) == 1
+ ]
+
+ if single_char_emails:
+ print(
+ f"BUG DETECTED: Found {len(single_char_emails)} single-character emails:"
+ )
+ for att in single_char_emails:
+ print(f" - '{att['email']}'")
+
+ # Should have attendees but not single-character emails
+ assert len(attendees) > 0
+ assert (
+ len(single_char_emails) == 0
+ ), f"Found {len(single_char_emails)} single-character emails, parsing is still buggy"
+
+ # Check that all emails are valid (contain @ symbol)
+ valid_emails = [
+ att for att in attendees if att.get("email") and "@" in att["email"]
+ ]
+ assert len(valid_emails) == len(
+ attendees
+ ), "Some attendees don't have valid email addresses"
+
+ # We expect around 29 attendees (28 from the comma-separated list + 1 organizer)
+ assert (
+ len(attendees) >= 25
+ ), f"Expected around 29 attendees, got {len(attendees)}"
+
+
+@pytest.mark.asyncio
+async def test_correct_attendee_parsing():
+ """
+ Test what correct attendee parsing should look like.
+ """
+ from datetime import datetime, timezone
+
+ from icalendar import Event
+
+ from reflector.services.ics_sync import ICSFetchService
+
+ service = ICSFetchService()
+
+ # Create a properly formatted event with multiple attendees
+ event = Event()
+ event.add("uid", "test-correct-attendees")
+ event.add("summary", "Test Meeting")
+ event.add("location", "http://test.com/test")
+ event.add("dtstart", datetime.now(timezone.utc))
+ event.add("dtend", datetime.now(timezone.utc))
+
+ # Add attendees the correct way (separate ATTENDEE lines)
+ event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"})
+ event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"})
+ event.add("attendee", "mailto:charlie@example.com", parameters={"CN": "Charlie"})
+ event.add(
+ "organizer", "mailto:organizer@example.com", parameters={"CN": "Organizer"}
+ )
+
+ # Parse the event
+ result = service._parse_event(event)
+
+ assert result is not None
+ attendees = result["attendees"]
+
+ # Should have 4 attendees (3 attendees + 1 organizer)
+ assert len(attendees) == 4
+
+ # Check that all emails are valid email addresses
+ emails = [att["email"] for att in attendees if att.get("email")]
+ expected_emails = [
+ "alice@example.com",
+ "bob@example.com",
+ "charlie@example.com",
+ "organizer@example.com",
+ ]
+
+ for email in emails:
+ assert "@" in email, f"Invalid email format: {email}"
+ assert len(email) > 5, f"Email too short: {email}"
+
+ # Check that we have the expected emails
+ assert "alice@example.com" in emails
+ assert "bob@example.com" in emails
+ assert "charlie@example.com" in emails
+ assert "organizer@example.com" in emails
diff --git a/server/tests/test_calendar_event.py b/server/tests/test_calendar_event.py
new file mode 100644
index 00000000..ece5f56a
--- /dev/null
+++ b/server/tests/test_calendar_event.py
@@ -0,0 +1,424 @@
+"""
+Tests for CalendarEvent model.
+"""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
+from reflector.db.rooms import rooms_controller
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_create():
+ """Test creating a calendar event."""
+ # Create a room first
+ room = await rooms_controller.add(
+ name="test-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ # Create calendar event
+ now = datetime.now(timezone.utc)
+ event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="test-event-123",
+ title="Team Meeting",
+ description="Weekly team sync",
+ start_time=now + timedelta(hours=1),
+ end_time=now + timedelta(hours=2),
+ location=f"https://example.com/{room.name}",
+ attendees=[
+ {"email": "alice@example.com", "name": "Alice", "status": "ACCEPTED"},
+ {"email": "bob@example.com", "name": "Bob", "status": "TENTATIVE"},
+ ],
+ )
+
+ # Save event
+ saved_event = await calendar_events_controller.upsert(event)
+
+ assert saved_event.ics_uid == "test-event-123"
+ assert saved_event.title == "Team Meeting"
+ assert saved_event.room_id == room.id
+ assert len(saved_event.attendees) == 2
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_get_by_room():
+ """Test getting calendar events for a room."""
+ # Create room
+ room = await rooms_controller.add(
+ name="events-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ now = datetime.now(timezone.utc)
+
+ # Create multiple events
+ for i in range(3):
+ event = CalendarEvent(
+ room_id=room.id,
+ ics_uid=f"event-{i}",
+ title=f"Meeting {i}",
+ start_time=now + timedelta(hours=i),
+ end_time=now + timedelta(hours=i + 1),
+ )
+ await calendar_events_controller.upsert(event)
+
+ # Get events for room
+ events = await calendar_events_controller.get_by_room(room.id)
+
+ assert len(events) == 3
+ assert all(e.room_id == room.id for e in events)
+ assert events[0].title == "Meeting 0"
+ assert events[1].title == "Meeting 1"
+ assert events[2].title == "Meeting 2"
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_get_upcoming():
+ """Test getting upcoming events within time window."""
+ # Create room
+ room = await rooms_controller.add(
+ name="upcoming-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ now = datetime.now(timezone.utc)
+
+ # Create events at different times
+ # Past event (should not be included)
+ past_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="past-event",
+ title="Past Meeting",
+ start_time=now - timedelta(hours=2),
+ end_time=now - timedelta(hours=1),
+ )
+ await calendar_events_controller.upsert(past_event)
+
+ # Upcoming event within 30 minutes
+ upcoming_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="upcoming-event",
+ title="Upcoming Meeting",
+ start_time=now + timedelta(minutes=15),
+ end_time=now + timedelta(minutes=45),
+ )
+ await calendar_events_controller.upsert(upcoming_event)
+
+ # Currently happening event (started 10 minutes ago, ends in 20 minutes)
+ current_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="current-event",
+ title="Current Meeting",
+ start_time=now - timedelta(minutes=10),
+ end_time=now + timedelta(minutes=20),
+ )
+ await calendar_events_controller.upsert(current_event)
+
+ # Future event beyond 30 minutes
+ future_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="future-event",
+ title="Future Meeting",
+ start_time=now + timedelta(hours=2),
+ end_time=now + timedelta(hours=3),
+ )
+ await calendar_events_controller.upsert(future_event)
+
+ # Get upcoming events (default 120 minutes) - should include current, upcoming, and future
+ upcoming = await calendar_events_controller.get_upcoming(room.id)
+
+ assert len(upcoming) == 3
+ # Events should be sorted by start_time (current event first, then upcoming, then future)
+ assert upcoming[0].ics_uid == "current-event"
+ assert upcoming[1].ics_uid == "upcoming-event"
+ assert upcoming[2].ics_uid == "future-event"
+
+ # Get upcoming with custom window
+ upcoming_extended = await calendar_events_controller.get_upcoming(
+ room.id, minutes_ahead=180
+ )
+
+ assert len(upcoming_extended) == 3
+ # Events should be sorted by start_time
+ assert upcoming_extended[0].ics_uid == "current-event"
+ assert upcoming_extended[1].ics_uid == "upcoming-event"
+ assert upcoming_extended[2].ics_uid == "future-event"
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_get_upcoming_includes_currently_happening():
+ """Test that get_upcoming includes currently happening events but excludes ended events."""
+ # Create room
+ room = await rooms_controller.add(
+ name="current-happening-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ now = datetime.now(timezone.utc)
+
+ # Event that ended in the past (should NOT be included)
+ past_ended_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="past-ended-event",
+ title="Past Ended Meeting",
+ start_time=now - timedelta(hours=2),
+ end_time=now - timedelta(minutes=30),
+ )
+ await calendar_events_controller.upsert(past_ended_event)
+
+ # Event currently happening (started 10 minutes ago, ends in 20 minutes) - SHOULD be included
+ currently_happening_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="currently-happening",
+ title="Currently Happening Meeting",
+ start_time=now - timedelta(minutes=10),
+ end_time=now + timedelta(minutes=20),
+ )
+ await calendar_events_controller.upsert(currently_happening_event)
+
+ # Event starting soon (in 5 minutes) - SHOULD be included
+ upcoming_soon_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="upcoming-soon",
+ title="Upcoming Soon Meeting",
+ start_time=now + timedelta(minutes=5),
+ end_time=now + timedelta(minutes=35),
+ )
+ await calendar_events_controller.upsert(upcoming_soon_event)
+
+ # Get upcoming events
+ upcoming = await calendar_events_controller.get_upcoming(room.id, minutes_ahead=30)
+
+ # Should only include currently happening and upcoming soon events
+ assert len(upcoming) == 2
+ assert upcoming[0].ics_uid == "currently-happening"
+ assert upcoming[1].ics_uid == "upcoming-soon"
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_upsert():
+ """Test upserting (create/update) calendar events."""
+ # Create room
+ room = await rooms_controller.add(
+ name="upsert-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ now = datetime.now(timezone.utc)
+
+ # Create new event
+ event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="upsert-test",
+ title="Original Title",
+ start_time=now,
+ end_time=now + timedelta(hours=1),
+ )
+
+ created = await calendar_events_controller.upsert(event)
+ assert created.title == "Original Title"
+
+ # Update existing event
+ event.title = "Updated Title"
+ event.description = "Added description"
+
+ updated = await calendar_events_controller.upsert(event)
+ assert updated.title == "Updated Title"
+ assert updated.description == "Added description"
+ assert updated.ics_uid == "upsert-test"
+
+ # Verify only one event exists
+ events = await calendar_events_controller.get_by_room(room.id)
+ assert len(events) == 1
+ assert events[0].title == "Updated Title"
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_soft_delete():
+ """Test soft deleting events no longer in calendar."""
+ # Create room
+ room = await rooms_controller.add(
+ name="delete-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ now = datetime.now(timezone.utc)
+
+ # Create multiple events
+ for i in range(4):
+ event = CalendarEvent(
+ room_id=room.id,
+ ics_uid=f"event-{i}",
+ title=f"Meeting {i}",
+ start_time=now + timedelta(hours=i),
+ end_time=now + timedelta(hours=i + 1),
+ )
+ await calendar_events_controller.upsert(event)
+
+ # Soft delete events not in current list
+ current_ids = ["event-0", "event-2"] # Keep events 0 and 2
+ deleted_count = await calendar_events_controller.soft_delete_missing(
+ room.id, current_ids
+ )
+
+ assert deleted_count == 2 # Should delete events 1 and 3
+
+ # Get non-deleted events
+ events = await calendar_events_controller.get_by_room(
+ room.id, include_deleted=False
+ )
+ assert len(events) == 2
+ assert {e.ics_uid for e in events} == {"event-0", "event-2"}
+
+ # Get all events including deleted
+ all_events = await calendar_events_controller.get_by_room(
+ room.id, include_deleted=True
+ )
+ assert len(all_events) == 4
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_past_events_not_deleted():
+ """Test that past events are not soft deleted."""
+ # Create room
+ room = await rooms_controller.add(
+ name="past-events-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ now = datetime.now(timezone.utc)
+
+ # Create past event
+ past_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="past-event",
+ title="Past Meeting",
+ start_time=now - timedelta(hours=2),
+ end_time=now - timedelta(hours=1),
+ )
+ await calendar_events_controller.upsert(past_event)
+
+ # Create future event
+ future_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="future-event",
+ title="Future Meeting",
+ start_time=now + timedelta(hours=1),
+ end_time=now + timedelta(hours=2),
+ )
+ await calendar_events_controller.upsert(future_event)
+
+ # Try to soft delete all events (only future should be deleted)
+ deleted_count = await calendar_events_controller.soft_delete_missing(room.id, [])
+
+ assert deleted_count == 1 # Only future event deleted
+
+ # Verify past event still exists
+ events = await calendar_events_controller.get_by_room(
+ room.id, include_deleted=False
+ )
+ assert len(events) == 1
+ assert events[0].ics_uid == "past-event"
+
+
+@pytest.mark.asyncio
+async def test_calendar_event_with_raw_ics_data():
+ """Test storing raw ICS data with calendar event."""
+ # Create room
+ room = await rooms_controller.add(
+ name="raw-ics-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ raw_ics = """BEGIN:VEVENT
+UID:test-raw-123
+SUMMARY:Test Event
+DTSTART:20240101T100000Z
+DTEND:20240101T110000Z
+END:VEVENT"""
+
+ event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="test-raw-123",
+ title="Test Event",
+ start_time=datetime.now(timezone.utc),
+ end_time=datetime.now(timezone.utc) + timedelta(hours=1),
+ ics_raw_data=raw_ics,
+ )
+
+ saved = await calendar_events_controller.upsert(event)
+
+ assert saved.ics_raw_data == raw_ics
+
+ # Retrieve and verify
+ retrieved = await calendar_events_controller.get_by_ics_uid(room.id, "test-raw-123")
+ assert retrieved is not None
+ assert retrieved.ics_raw_data == raw_ics
diff --git a/server/tests/test_ics_background_tasks.py b/server/tests/test_ics_background_tasks.py
new file mode 100644
index 00000000..c2bf5c87
--- /dev/null
+++ b/server/tests/test_ics_background_tasks.py
@@ -0,0 +1,255 @@
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from icalendar import Calendar, Event
+
+from reflector.db import get_database
+from reflector.db.calendar_events import calendar_events_controller
+from reflector.db.rooms import rooms, rooms_controller
+from reflector.services.ics_sync import ics_sync_service
+from reflector.worker.ics_sync import (
+ _should_sync,
+ sync_room_ics,
+)
+
+
+@pytest.mark.asyncio
+async def test_sync_room_ics_task():
+ room = await rooms_controller.add(
+ name="task-test-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/task.ics",
+ ics_enabled=True,
+ )
+
+ cal = Calendar()
+ event = Event()
+ event.add("uid", "task-event-1")
+ event.add("summary", "Task Test Meeting")
+ from reflector.settings import settings
+
+ event.add("location", f"{settings.UI_BASE_URL}/{room.name}")
+ now = datetime.now(timezone.utc)
+ event.add("dtstart", now + timedelta(hours=1))
+ event.add("dtend", now + timedelta(hours=2))
+ cal.add_component(event)
+ ics_content = cal.to_ical().decode("utf-8")
+
+ with patch(
+ "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock
+ ) as mock_fetch:
+ mock_fetch.return_value = ics_content
+
+ # Call the service directly instead of the Celery task to avoid event loop issues
+ await ics_sync_service.sync_room_calendar(room)
+
+ events = await calendar_events_controller.get_by_room(room.id)
+ assert len(events) == 1
+ assert events[0].ics_uid == "task-event-1"
+
+
+@pytest.mark.asyncio
+async def test_sync_room_ics_disabled():
+ room = await rooms_controller.add(
+ name="disabled-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_enabled=False,
+ )
+
+ # Test that disabled rooms are skipped by the service
+ result = await ics_sync_service.sync_room_calendar(room)
+
+ events = await calendar_events_controller.get_by_room(room.id)
+ assert len(events) == 0
+
+
+@pytest.mark.asyncio
+async def test_sync_all_ics_calendars():
+ room1 = await rooms_controller.add(
+ name="sync-all-1",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/1.ics",
+ ics_enabled=True,
+ )
+
+ room2 = await rooms_controller.add(
+ name="sync-all-2",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/2.ics",
+ ics_enabled=True,
+ )
+
+ room3 = await rooms_controller.add(
+ name="sync-all-3",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_enabled=False,
+ )
+
+ with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay:
+ # Directly call the sync_all logic without the Celery wrapper
+ query = rooms.select().where(
+ rooms.c.ics_enabled == True, rooms.c.ics_url != None
+ )
+ all_rooms = await get_database().fetch_all(query)
+
+ for room_data in all_rooms:
+ room_id = room_data["id"]
+ room = await rooms_controller.get_by_id(room_id)
+ if room and _should_sync(room):
+ sync_room_ics.delay(room_id)
+
+ assert mock_delay.call_count == 2
+ called_room_ids = [call.args[0] for call in mock_delay.call_args_list]
+ assert room1.id in called_room_ids
+ assert room2.id in called_room_ids
+ assert room3.id not in called_room_ids
+
+
+@pytest.mark.asyncio
+async def test_should_sync_logic():
+ room = MagicMock()
+
+ room.ics_last_sync = None
+ assert _should_sync(room) is True
+
+ room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100)
+ room.ics_fetch_interval = 300
+ assert _should_sync(room) is False
+
+ room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400)
+ room.ics_fetch_interval = 300
+ assert _should_sync(room) is True
+
+
+@pytest.mark.asyncio
+async def test_sync_respects_fetch_interval():
+ now = datetime.now(timezone.utc)
+
+ room1 = await rooms_controller.add(
+ name="interval-test-1",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/interval.ics",
+ ics_enabled=True,
+ ics_fetch_interval=300,
+ )
+
+ await rooms_controller.update(
+ room1,
+ {"ics_last_sync": now - timedelta(seconds=100)},
+ )
+
+ room2 = await rooms_controller.add(
+ name="interval-test-2",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/interval2.ics",
+ ics_enabled=True,
+ ics_fetch_interval=60,
+ )
+
+ await rooms_controller.update(
+ room2,
+ {"ics_last_sync": now - timedelta(seconds=100)},
+ )
+
+ with patch("reflector.worker.ics_sync.sync_room_ics.delay") as mock_delay:
+ # Test the sync logic without the Celery wrapper
+ query = rooms.select().where(
+ rooms.c.ics_enabled == True, rooms.c.ics_url != None
+ )
+ all_rooms = await get_database().fetch_all(query)
+
+ for room_data in all_rooms:
+ room_id = room_data["id"]
+ room = await rooms_controller.get_by_id(room_id)
+ if room and _should_sync(room):
+ sync_room_ics.delay(room_id)
+
+ assert mock_delay.call_count == 1
+ assert mock_delay.call_args[0][0] == room2.id
+
+
+@pytest.mark.asyncio
+async def test_sync_handles_errors_gracefully():
+ room = await rooms_controller.add(
+ name="error-task-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/error.ics",
+ ics_enabled=True,
+ )
+
+ with patch(
+ "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock
+ ) as mock_fetch:
+ mock_fetch.side_effect = Exception("Network error")
+
+ # Call the service directly to test error handling
+ result = await ics_sync_service.sync_room_calendar(room)
+ assert result["status"] == "error"
+
+ events = await calendar_events_controller.get_by_room(room.id)
+ assert len(events) == 0
diff --git a/server/tests/test_ics_sync.py b/server/tests/test_ics_sync.py
new file mode 100644
index 00000000..e448dd7d
--- /dev/null
+++ b/server/tests/test_ics_sync.py
@@ -0,0 +1,290 @@
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from icalendar import Calendar, Event
+
+from reflector.db.calendar_events import calendar_events_controller
+from reflector.db.rooms import rooms_controller
+from reflector.services.ics_sync import ICSFetchService, ICSSyncService
+
+
+@pytest.mark.asyncio
+async def test_ics_fetch_service_event_matching():
+ service = ICSFetchService()
+ room_name = "test-room"
+ room_url = "https://example.com/test-room"
+
+ # Create test event
+ event = Event()
+ event.add("uid", "test-123")
+ event.add("summary", "Test Meeting")
+
+ # Test matching with full URL in location
+ event.add("location", "https://example.com/test-room")
+ assert service._event_matches_room(event, room_name, room_url) is True
+
+ # Test non-matching with URL without protocol (exact matching only now)
+ event["location"] = "example.com/test-room"
+ assert service._event_matches_room(event, room_name, room_url) is False
+
+ # Test matching in description
+ event["location"] = "Conference Room A"
+ event.add("description", f"Join at {room_url}")
+ assert service._event_matches_room(event, room_name, room_url) is True
+
+ # Test non-matching
+ event["location"] = "Different Room"
+ event["description"] = "No room URL here"
+ assert service._event_matches_room(event, room_name, room_url) is False
+
+ # Test partial paths should NOT match anymore
+ event["location"] = "/test-room"
+ assert service._event_matches_room(event, room_name, room_url) is False
+
+ event["location"] = f"Room: {room_name}"
+ assert service._event_matches_room(event, room_name, room_url) is False
+
+
+@pytest.mark.asyncio
+async def test_ics_fetch_service_parse_event():
+ service = ICSFetchService()
+
+ # Create test event
+ event = Event()
+ event.add("uid", "test-456")
+ event.add("summary", "Team Standup")
+ event.add("description", "Daily team sync")
+ event.add("location", "https://example.com/standup")
+
+ now = datetime.now(timezone.utc)
+ event.add("dtstart", now)
+ event.add("dtend", now + timedelta(hours=1))
+
+ # Add attendees
+ event.add("attendee", "mailto:alice@example.com", parameters={"CN": "Alice"})
+ event.add("attendee", "mailto:bob@example.com", parameters={"CN": "Bob"})
+ event.add("organizer", "mailto:carol@example.com", parameters={"CN": "Carol"})
+
+ # Parse event
+ result = service._parse_event(event)
+
+ assert result is not None
+ assert result["ics_uid"] == "test-456"
+ assert result["title"] == "Team Standup"
+ assert result["description"] == "Daily team sync"
+ assert result["location"] == "https://example.com/standup"
+ assert len(result["attendees"]) == 3 # 2 attendees + 1 organizer
+
+
+@pytest.mark.asyncio
+async def test_ics_fetch_service_extract_room_events():
+ service = ICSFetchService()
+ room_name = "meeting"
+ room_url = "https://example.com/meeting"
+
+ # Create calendar with multiple events
+ cal = Calendar()
+
+ # Event 1: Matches room
+ event1 = Event()
+ event1.add("uid", "match-1")
+ event1.add("summary", "Planning Meeting")
+ event1.add("location", room_url)
+ now = datetime.now(timezone.utc)
+ event1.add("dtstart", now + timedelta(hours=2))
+ event1.add("dtend", now + timedelta(hours=3))
+ cal.add_component(event1)
+
+ # Event 2: Doesn't match room
+ event2 = Event()
+ event2.add("uid", "no-match")
+ event2.add("summary", "Other Meeting")
+ event2.add("location", "https://example.com/other")
+ event2.add("dtstart", now + timedelta(hours=4))
+ event2.add("dtend", now + timedelta(hours=5))
+ cal.add_component(event2)
+
+ # Event 3: Matches room in description
+ event3 = Event()
+ event3.add("uid", "match-2")
+ event3.add("summary", "Review Session")
+ event3.add("description", f"Meeting link: {room_url}")
+ event3.add("dtstart", now + timedelta(hours=6))
+ event3.add("dtend", now + timedelta(hours=7))
+ cal.add_component(event3)
+
+ # Event 4: Cancelled event (should be skipped)
+ event4 = Event()
+ event4.add("uid", "cancelled")
+ event4.add("summary", "Cancelled Meeting")
+ event4.add("location", room_url)
+ event4.add("status", "CANCELLED")
+ event4.add("dtstart", now + timedelta(hours=8))
+ event4.add("dtend", now + timedelta(hours=9))
+ cal.add_component(event4)
+
+ # Extract events
+ events, total_events = service.extract_room_events(cal, room_name, room_url)
+
+ assert len(events) == 2
+ assert total_events == 3 # 3 events in time window (excluding cancelled)
+ assert events[0]["ics_uid"] == "match-1"
+ assert events[1]["ics_uid"] == "match-2"
+
+
+@pytest.mark.asyncio
+async def test_ics_sync_service_sync_room_calendar():
+ # Create room
+ room = await rooms_controller.add(
+ name="sync-test",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/test.ics",
+ ics_enabled=True,
+ )
+
+ # Mock ICS content
+ cal = Calendar()
+ event = Event()
+ event.add("uid", "sync-event-1")
+ event.add("summary", "Sync Test Meeting")
+ # Use the actual UI_BASE_URL from settings
+ from reflector.settings import settings
+
+ event.add("location", f"{settings.UI_BASE_URL}/{room.name}")
+ now = datetime.now(timezone.utc)
+ event.add("dtstart", now + timedelta(hours=1))
+ event.add("dtend", now + timedelta(hours=2))
+ cal.add_component(event)
+ ics_content = cal.to_ical().decode("utf-8")
+
+ # Create sync service and mock fetch
+ sync_service = ICSSyncService()
+
+ with patch.object(
+ sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock
+ ) as mock_fetch:
+ mock_fetch.return_value = ics_content
+
+ # First sync
+ result = await sync_service.sync_room_calendar(room)
+
+ assert result["status"] == "success"
+ assert result["events_found"] == 1
+ assert result["events_created"] == 1
+ assert result["events_updated"] == 0
+ assert result["events_deleted"] == 0
+
+ # Verify event was created
+ events = await calendar_events_controller.get_by_room(room.id)
+ assert len(events) == 1
+ assert events[0].ics_uid == "sync-event-1"
+ assert events[0].title == "Sync Test Meeting"
+
+ # Second sync with same content (should be unchanged)
+ # Refresh room to get updated etag and force sync by setting old sync time
+ room = await rooms_controller.get_by_id(room.id)
+ await rooms_controller.update(
+ room, {"ics_last_sync": datetime.now(timezone.utc) - timedelta(minutes=10)}
+ )
+ result = await sync_service.sync_room_calendar(room)
+ assert result["status"] == "unchanged"
+
+ # Third sync with updated event
+ event["summary"] = "Updated Meeting Title"
+ cal = Calendar()
+ cal.add_component(event)
+ ics_content = cal.to_ical().decode("utf-8")
+ mock_fetch.return_value = ics_content
+
+ # Force sync by clearing etag
+ await rooms_controller.update(room, {"ics_last_etag": None})
+
+ result = await sync_service.sync_room_calendar(room)
+ assert result["status"] == "success"
+ assert result["events_created"] == 0
+ assert result["events_updated"] == 1
+
+ # Verify event was updated
+ events = await calendar_events_controller.get_by_room(room.id)
+ assert len(events) == 1
+ assert events[0].title == "Updated Meeting Title"
+
+
+@pytest.mark.asyncio
+async def test_ics_sync_service_should_sync():
+ service = ICSSyncService()
+
+ # Room never synced
+ room = MagicMock()
+ room.ics_last_sync = None
+ room.ics_fetch_interval = 300
+ assert service._should_sync(room) is True
+
+ # Room synced recently
+ room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=100)
+ assert service._should_sync(room) is False
+
+ # Room sync due
+ room.ics_last_sync = datetime.now(timezone.utc) - timedelta(seconds=400)
+ assert service._should_sync(room) is True
+
+
+@pytest.mark.asyncio
+async def test_ics_sync_service_skip_disabled():
+ service = ICSSyncService()
+
+ # Room with ICS disabled
+ room = MagicMock()
+ room.ics_enabled = False
+ room.ics_url = "https://calendar.example.com/test.ics"
+
+ result = await service.sync_room_calendar(room)
+ assert result["status"] == "skipped"
+ assert result["reason"] == "ICS not configured"
+
+ # Room without URL
+ room.ics_enabled = True
+ room.ics_url = None
+
+ result = await service.sync_room_calendar(room)
+ assert result["status"] == "skipped"
+ assert result["reason"] == "ICS not configured"
+
+
+@pytest.mark.asyncio
+async def test_ics_sync_service_error_handling():
+ # Create room
+ room = await rooms_controller.add(
+ name="error-test",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/error.ics",
+ ics_enabled=True,
+ )
+
+ sync_service = ICSSyncService()
+
+ with patch.object(
+ sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock
+ ) as mock_fetch:
+ mock_fetch.side_effect = Exception("Network error")
+
+ result = await sync_service.sync_room_calendar(room)
+ assert result["status"] == "error"
+ assert "Network error" in result["error"]
diff --git a/server/tests/test_multiple_active_meetings.py b/server/tests/test_multiple_active_meetings.py
new file mode 100644
index 00000000..61bce0e0
--- /dev/null
+++ b/server/tests/test_multiple_active_meetings.py
@@ -0,0 +1,167 @@
+"""Tests for multiple active meetings per room functionality."""
+
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
+from reflector.db.meetings import meetings_controller
+from reflector.db.rooms import rooms_controller
+
+
+@pytest.mark.asyncio
+async def test_multiple_active_meetings_per_room():
+ """Test that multiple active meetings can exist for the same room."""
+ # Create a room
+ room = await rooms_controller.add(
+ name="test-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ current_time = datetime.now(timezone.utc)
+ end_time = current_time + timedelta(hours=2)
+
+ # Create first meeting
+ meeting1 = await meetings_controller.create(
+ id="meeting-1",
+ room_name="test-meeting-1",
+ room_url="https://whereby.com/test-1",
+ host_room_url="https://whereby.com/test-1-host",
+ start_date=current_time,
+ end_date=end_time,
+ room=room,
+ )
+
+ # Create second meeting for the same room (should succeed now)
+ meeting2 = await meetings_controller.create(
+ id="meeting-2",
+ room_name="test-meeting-2",
+ room_url="https://whereby.com/test-2",
+ host_room_url="https://whereby.com/test-2-host",
+ start_date=current_time,
+ end_date=end_time,
+ room=room,
+ )
+
+ # Both meetings should be active
+ active_meetings = await meetings_controller.get_all_active_for_room(
+ room=room, current_time=current_time
+ )
+
+ assert len(active_meetings) == 2
+ assert meeting1.id in [m.id for m in active_meetings]
+ assert meeting2.id in [m.id for m in active_meetings]
+
+
+@pytest.mark.asyncio
+async def test_get_active_by_calendar_event():
+ """Test getting active meeting by calendar event ID."""
+ # Create a room
+ room = await rooms_controller.add(
+ name="test-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ # Create a calendar event
+ event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="test-event-uid",
+ title="Test Meeting",
+ start_time=datetime.now(timezone.utc),
+ end_time=datetime.now(timezone.utc) + timedelta(hours=1),
+ )
+ event = await calendar_events_controller.upsert(event)
+
+ current_time = datetime.now(timezone.utc)
+ end_time = current_time + timedelta(hours=2)
+
+ # Create meeting linked to calendar event
+ meeting = await meetings_controller.create(
+ id="meeting-cal-1",
+ room_name="test-meeting-cal",
+ room_url="https://whereby.com/test-cal",
+ host_room_url="https://whereby.com/test-cal-host",
+ start_date=current_time,
+ end_date=end_time,
+ room=room,
+ calendar_event_id=event.id,
+ calendar_metadata={"title": event.title},
+ )
+
+ # Should find the meeting by calendar event
+ found_meeting = await meetings_controller.get_active_by_calendar_event(
+ room=room, calendar_event_id=event.id, current_time=current_time
+ )
+
+ assert found_meeting is not None
+ assert found_meeting.id == meeting.id
+ assert found_meeting.calendar_event_id == event.id
+
+
+@pytest.mark.asyncio
+async def test_calendar_meeting_deactivates_after_scheduled_end():
+ """Test that unused calendar meetings deactivate after scheduled end time."""
+ # Create a room
+ room = await rooms_controller.add(
+ name="test-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ # Create a calendar event that ended 35 minutes ago
+ event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="test-event-unused",
+ title="Test Meeting Unused",
+ start_time=datetime.now(timezone.utc) - timedelta(hours=2),
+ end_time=datetime.now(timezone.utc) - timedelta(minutes=35),
+ )
+ event = await calendar_events_controller.upsert(event)
+
+ current_time = datetime.now(timezone.utc)
+
+ # Create meeting linked to calendar event
+ meeting = await meetings_controller.create(
+ id="meeting-unused",
+ room_name="test-meeting-unused",
+ room_url="https://whereby.com/test-unused",
+ host_room_url="https://whereby.com/test-unused-host",
+ start_date=event.start_time,
+ end_date=event.end_time,
+ room=room,
+ calendar_event_id=event.id,
+ )
+
+ # Test the new logic: unused calendar meetings deactivate after scheduled end
+ # The meeting ended 35 minutes ago and was never used, so it should be deactivated
+
+ # Simulate process_meetings logic for unused calendar meeting past end time
+ if meeting.calendar_event_id and current_time > meeting.end_date:
+ # In real code, we'd check has_had_sessions = False here
+ await meetings_controller.update_meeting(meeting.id, is_active=False)
+
+ updated_meeting = await meetings_controller.get_by_id(meeting.id)
+ assert updated_meeting.is_active is False # Deactivated after scheduled end
diff --git a/server/tests/test_room_ics.py b/server/tests/test_room_ics.py
new file mode 100644
index 00000000..7a3c4d74
--- /dev/null
+++ b/server/tests/test_room_ics.py
@@ -0,0 +1,225 @@
+"""
+Tests for Room model ICS calendar integration fields.
+"""
+
+from datetime import datetime, timezone
+
+import pytest
+
+from reflector.db.rooms import rooms_controller
+
+
+@pytest.mark.asyncio
+async def test_room_create_with_ics_fields():
+ """Test creating a room with ICS calendar fields."""
+ room = await rooms_controller.add(
+ name="test-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.google.com/calendar/ical/test/private-token/basic.ics",
+ ics_fetch_interval=600,
+ ics_enabled=True,
+ )
+
+ assert room.name == "test-room"
+ assert (
+ room.ics_url
+ == "https://calendar.google.com/calendar/ical/test/private-token/basic.ics"
+ )
+ assert room.ics_fetch_interval == 600
+ assert room.ics_enabled is True
+ assert room.ics_last_sync is None
+ assert room.ics_last_etag is None
+
+
+@pytest.mark.asyncio
+async def test_room_update_ics_configuration():
+ """Test updating room ICS configuration."""
+ # Create room without ICS
+ room = await rooms_controller.add(
+ name="update-test",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ assert room.ics_enabled is False
+ assert room.ics_url is None
+
+ # Update with ICS configuration
+ await rooms_controller.update(
+ room,
+ {
+ "ics_url": "https://outlook.office365.com/owa/calendar/test/calendar.ics",
+ "ics_fetch_interval": 300,
+ "ics_enabled": True,
+ },
+ )
+
+ assert (
+ room.ics_url == "https://outlook.office365.com/owa/calendar/test/calendar.ics"
+ )
+ assert room.ics_fetch_interval == 300
+ assert room.ics_enabled is True
+
+
+@pytest.mark.asyncio
+async def test_room_ics_sync_metadata():
+ """Test updating room ICS sync metadata."""
+ room = await rooms_controller.add(
+ name="sync-test",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://example.com/calendar.ics",
+ ics_enabled=True,
+ )
+
+ # Update sync metadata
+ sync_time = datetime.now(timezone.utc)
+ await rooms_controller.update(
+ room,
+ {
+ "ics_last_sync": sync_time,
+ "ics_last_etag": "abc123hash",
+ },
+ )
+
+ assert room.ics_last_sync == sync_time
+ assert room.ics_last_etag == "abc123hash"
+
+
+@pytest.mark.asyncio
+async def test_room_get_with_ics_fields():
+ """Test retrieving room with ICS fields."""
+ # Create room
+ created_room = await rooms_controller.add(
+ name="get-test",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="webcal://calendar.example.com/feed.ics",
+ ics_fetch_interval=900,
+ ics_enabled=True,
+ )
+
+ # Get by ID
+ room = await rooms_controller.get_by_id(created_room.id)
+ assert room is not None
+ assert room.ics_url == "webcal://calendar.example.com/feed.ics"
+ assert room.ics_fetch_interval == 900
+ assert room.ics_enabled is True
+
+ # Get by name
+ room = await rooms_controller.get_by_name("get-test")
+ assert room is not None
+ assert room.ics_url == "webcal://calendar.example.com/feed.ics"
+ assert room.ics_fetch_interval == 900
+ assert room.ics_enabled is True
+
+
+@pytest.mark.asyncio
+async def test_room_list_with_ics_enabled_filter():
+ """Test listing rooms filtered by ICS enabled status."""
+ # Create rooms with and without ICS
+ room1 = await rooms_controller.add(
+ name="ics-enabled-1",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=True,
+ ics_enabled=True,
+ ics_url="https://calendar1.example.com/feed.ics",
+ )
+
+ room2 = await rooms_controller.add(
+ name="ics-disabled",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=True,
+ ics_enabled=False,
+ )
+
+ room3 = await rooms_controller.add(
+ name="ics-enabled-2",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=True,
+ ics_enabled=True,
+ ics_url="https://calendar2.example.com/feed.ics",
+ )
+
+ # Get all rooms
+ all_rooms = await rooms_controller.get_all()
+ assert len(all_rooms) == 3
+
+ # Filter for ICS-enabled rooms (would need to implement this in controller)
+ ics_rooms = [r for r in all_rooms if r["ics_enabled"]]
+ assert len(ics_rooms) == 2
+ assert all(r["ics_enabled"] for r in ics_rooms)
+
+
+@pytest.mark.asyncio
+async def test_room_default_ics_values():
+ """Test that ICS fields have correct default values."""
+ room = await rooms_controller.add(
+ name="default-test",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ # Don't specify ICS fields
+ )
+
+ assert room.ics_url is None
+ assert room.ics_fetch_interval == 300 # Default 5 minutes
+ assert room.ics_enabled is False
+ assert room.ics_last_sync is None
+ assert room.ics_last_etag is None
diff --git a/server/tests/test_room_ics_api.py b/server/tests/test_room_ics_api.py
new file mode 100644
index 00000000..27a784d7
--- /dev/null
+++ b/server/tests/test_room_ics_api.py
@@ -0,0 +1,390 @@
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from icalendar import Calendar, Event
+
+from reflector.db.calendar_events import CalendarEvent, calendar_events_controller
+from reflector.db.rooms import rooms_controller
+
+
+@pytest.fixture
+async def authenticated_client(client):
+ from reflector.app import app
+ from reflector.auth import current_user_optional
+
+ app.dependency_overrides[current_user_optional] = lambda: {
+ "sub": "test-user",
+ "email": "test@example.com",
+ }
+ yield client
+ del app.dependency_overrides[current_user_optional]
+
+
+@pytest.mark.asyncio
+async def test_create_room_with_ics_fields(authenticated_client):
+ client = authenticated_client
+ response = await client.post(
+ "/rooms",
+ json={
+ "name": "test-ics-room",
+ "zulip_auto_post": False,
+ "zulip_stream": "",
+ "zulip_topic": "",
+ "is_locked": False,
+ "room_mode": "normal",
+ "recording_type": "cloud",
+ "recording_trigger": "automatic-2nd-participant",
+ "is_shared": False,
+ "webhook_url": "",
+ "webhook_secret": "",
+ "ics_url": "https://calendar.example.com/test.ics",
+ "ics_fetch_interval": 600,
+ "ics_enabled": True,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["name"] == "test-ics-room"
+ assert data["ics_url"] == "https://calendar.example.com/test.ics"
+ assert data["ics_fetch_interval"] == 600
+ assert data["ics_enabled"] is True
+
+
+@pytest.mark.asyncio
+async def test_update_room_ics_configuration(authenticated_client):
+ client = authenticated_client
+ response = await client.post(
+ "/rooms",
+ json={
+ "name": "update-ics-room",
+ "zulip_auto_post": False,
+ "zulip_stream": "",
+ "zulip_topic": "",
+ "is_locked": False,
+ "room_mode": "normal",
+ "recording_type": "cloud",
+ "recording_trigger": "automatic-2nd-participant",
+ "is_shared": False,
+ "webhook_url": "",
+ "webhook_secret": "",
+ },
+ )
+ assert response.status_code == 200
+ room_id = response.json()["id"]
+
+ response = await client.patch(
+ f"/rooms/{room_id}",
+ json={
+ "ics_url": "https://calendar.google.com/updated.ics",
+ "ics_fetch_interval": 300,
+ "ics_enabled": True,
+ },
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["ics_url"] == "https://calendar.google.com/updated.ics"
+ assert data["ics_fetch_interval"] == 300
+ assert data["ics_enabled"] is True
+
+
+@pytest.mark.asyncio
+async def test_trigger_ics_sync(authenticated_client):
+ client = authenticated_client
+ room = await rooms_controller.add(
+ name="sync-api-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/api.ics",
+ ics_enabled=True,
+ )
+
+ cal = Calendar()
+ event = Event()
+ event.add("uid", "api-test-event")
+ event.add("summary", "API Test Meeting")
+ from reflector.settings import settings
+
+ event.add("location", f"{settings.UI_BASE_URL}/{room.name}")
+ now = datetime.now(timezone.utc)
+ event.add("dtstart", now + timedelta(hours=1))
+ event.add("dtend", now + timedelta(hours=2))
+ cal.add_component(event)
+ ics_content = cal.to_ical().decode("utf-8")
+
+ with patch(
+ "reflector.services.ics_sync.ICSFetchService.fetch_ics", new_callable=AsyncMock
+ ) as mock_fetch:
+ mock_fetch.return_value = ics_content
+
+ response = await client.post(f"/rooms/{room.name}/ics/sync")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "success"
+ assert data["events_found"] == 1
+ assert data["events_created"] == 1
+
+
+@pytest.mark.asyncio
+async def test_trigger_ics_sync_unauthorized(client):
+ room = await rooms_controller.add(
+ name="sync-unauth-room",
+ user_id="owner-123",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/api.ics",
+ ics_enabled=True,
+ )
+
+ response = await client.post(f"/rooms/{room.name}/ics/sync")
+ assert response.status_code == 403
+ assert "Only room owner can trigger ICS sync" in response.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_trigger_ics_sync_not_configured(authenticated_client):
+ client = authenticated_client
+ room = await rooms_controller.add(
+ name="sync-not-configured",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_enabled=False,
+ )
+
+ response = await client.post(f"/rooms/{room.name}/ics/sync")
+ assert response.status_code == 400
+ assert "ICS not configured" in response.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_get_ics_status(authenticated_client):
+ client = authenticated_client
+ room = await rooms_controller.add(
+ name="status-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/status.ics",
+ ics_enabled=True,
+ ics_fetch_interval=300,
+ )
+
+ now = datetime.now(timezone.utc)
+ await rooms_controller.update(
+ room,
+ {"ics_last_sync": now, "ics_last_etag": "test-etag"},
+ )
+
+ response = await client.get(f"/rooms/{room.name}/ics/status")
+ assert response.status_code == 200
+ data = response.json()
+ assert data["status"] == "enabled"
+ assert data["last_etag"] == "test-etag"
+ assert data["events_count"] == 0
+
+
+@pytest.mark.asyncio
+async def test_get_ics_status_unauthorized(client):
+ room = await rooms_controller.add(
+ name="status-unauth",
+ user_id="owner-456",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ ics_url="https://calendar.example.com/status.ics",
+ ics_enabled=True,
+ )
+
+ response = await client.get(f"/rooms/{room.name}/ics/status")
+ assert response.status_code == 403
+ assert "Only room owner can view ICS status" in response.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_list_room_meetings(authenticated_client):
+ client = authenticated_client
+ room = await rooms_controller.add(
+ name="meetings-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ now = datetime.now(timezone.utc)
+ event1 = CalendarEvent(
+ room_id=room.id,
+ ics_uid="meeting-1",
+ title="Past Meeting",
+ start_time=now - timedelta(hours=2),
+ end_time=now - timedelta(hours=1),
+ )
+ await calendar_events_controller.upsert(event1)
+
+ event2 = CalendarEvent(
+ room_id=room.id,
+ ics_uid="meeting-2",
+ title="Future Meeting",
+ description="Team sync",
+ start_time=now + timedelta(hours=1),
+ end_time=now + timedelta(hours=2),
+ attendees=[{"email": "test@example.com"}],
+ )
+ await calendar_events_controller.upsert(event2)
+
+ response = await client.get(f"/rooms/{room.name}/meetings")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) == 2
+ assert data[0]["title"] == "Past Meeting"
+ assert data[1]["title"] == "Future Meeting"
+ assert data[1]["description"] == "Team sync"
+ assert data[1]["attendees"] == [{"email": "test@example.com"}]
+
+
+@pytest.mark.asyncio
+async def test_list_room_meetings_non_owner(client):
+ room = await rooms_controller.add(
+ name="meetings-privacy",
+ user_id="owner-789",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="private-meeting",
+ title="Meeting Title",
+ description="Sensitive info",
+ start_time=datetime.now(timezone.utc) + timedelta(hours=1),
+ end_time=datetime.now(timezone.utc) + timedelta(hours=2),
+ attendees=[{"email": "private@example.com"}],
+ )
+ await calendar_events_controller.upsert(event)
+
+ response = await client.get(f"/rooms/{room.name}/meetings")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) == 1
+ assert data[0]["title"] == "Meeting Title"
+ assert data[0]["description"] is None
+ assert data[0]["attendees"] is None
+
+
+@pytest.mark.asyncio
+async def test_list_upcoming_meetings(authenticated_client):
+ client = authenticated_client
+ room = await rooms_controller.add(
+ name="upcoming-room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ )
+
+ now = datetime.now(timezone.utc)
+
+ past_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="past",
+ title="Past",
+ start_time=now - timedelta(hours=1),
+ end_time=now - timedelta(minutes=30),
+ )
+ await calendar_events_controller.upsert(past_event)
+
+ soon_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="soon",
+ title="Soon",
+ start_time=now + timedelta(minutes=15),
+ end_time=now + timedelta(minutes=45),
+ )
+ await calendar_events_controller.upsert(soon_event)
+
+ later_event = CalendarEvent(
+ room_id=room.id,
+ ics_uid="later",
+ title="Later",
+ start_time=now + timedelta(hours=2),
+ end_time=now + timedelta(hours=3),
+ )
+ await calendar_events_controller.upsert(later_event)
+
+ response = await client.get(f"/rooms/{room.name}/meetings/upcoming")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) == 2
+ assert data[0]["title"] == "Soon"
+ assert data[1]["title"] == "Later"
+
+ response = await client.get(
+ f"/rooms/{room.name}/meetings/upcoming", params={"minutes_ahead": 180}
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) == 2
+ assert data[0]["title"] == "Soon"
+ assert data[1]["title"] == "Later"
+
+
+@pytest.mark.asyncio
+async def test_room_not_found_endpoints(client):
+ response = await client.post("/rooms/nonexistent/ics/sync")
+ assert response.status_code == 404
+
+ response = await client.get("/rooms/nonexistent/ics/status")
+ assert response.status_code == 404
+
+ response = await client.get("/rooms/nonexistent/meetings")
+ assert response.status_code == 404
+
+ response = await client.get("/rooms/nonexistent/meetings/upcoming")
+ assert response.status_code == 404
diff --git a/server/uv.lock b/server/uv.lock
index b93d0ac3..2c28f61b 100644
--- a/server/uv.lock
+++ b/server/uv.lock
@@ -2,13 +2,15 @@ version = 1
revision = 3
requires-python = ">=3.11, <3.13"
resolution-markers = [
- "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'",
+ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'",
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'",
+ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
"python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'",
- "python_full_version < '3.12' and platform_python_implementation == 'PyPy'",
+ "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'",
"(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'",
+ "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
"python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform == 'darwin'",
]
@@ -1035,27 +1037,27 @@ wheels = [
[[package]]
name = "fonttools"
-version = "4.59.1"
+version = "4.59.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/11/7f/29c9c3fe4246f6ad96fee52b88d0dc3a863c7563b0afc959e36d78b965dc/fonttools-4.59.1.tar.gz", hash = "sha256:74995b402ad09822a4c8002438e54940d9f1ecda898d2bb057729d7da983e4cb", size = 3534394, upload-time = "2025-08-14T16:28:14.266Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/a5/fba25f9fbdab96e26dedcaeeba125e5f05a09043bf888e0305326e55685b/fonttools-4.59.2.tar.gz", hash = "sha256:e72c0749b06113f50bcb80332364c6be83a9582d6e3db3fe0b280f996dc2ef22", size = 3540889, upload-time = "2025-08-27T16:40:30.97Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/34/62/9667599561f623d4a523cc9eb4f66f3b94b6155464110fa9aebbf90bbec7/fonttools-4.59.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4909cce2e35706f3d18c54d3dcce0414ba5e0fb436a454dffec459c61653b513", size = 2778815, upload-time = "2025-08-14T16:26:28.484Z" },
- { url = "https://files.pythonhosted.org/packages/8f/78/cc25bcb2ce86033a9df243418d175e58f1956a35047c685ef553acae67d6/fonttools-4.59.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:efbec204fa9f877641747f2d9612b2b656071390d7a7ef07a9dbf0ecf9c7195c", size = 2341631, upload-time = "2025-08-14T16:26:30.396Z" },
- { url = "https://files.pythonhosted.org/packages/a4/cc/fcbb606dd6871f457ac32f281c20bcd6cc77d9fce77b5a4e2b2afab1f500/fonttools-4.59.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39dfd42cc2dc647b2c5469bc7a5b234d9a49e72565b96dd14ae6f11c2c59ef15", size = 5022222, upload-time = "2025-08-14T16:26:32.447Z" },
- { url = "https://files.pythonhosted.org/packages/61/96/c0b1cf2b74d08eb616a80dbf5564351fe4686147291a25f7dce8ace51eb3/fonttools-4.59.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b11bc177a0d428b37890825d7d025040d591aa833f85f8d8878ed183354f47df", size = 4966512, upload-time = "2025-08-14T16:26:34.621Z" },
- { url = "https://files.pythonhosted.org/packages/a4/26/51ce2e3e0835ffc2562b1b11d1fb9dafd0aca89c9041b64a9e903790a761/fonttools-4.59.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b9b4c35b3be45e5bc774d3fc9608bbf4f9a8d371103b858c80edbeed31dd5aa", size = 5001645, upload-time = "2025-08-14T16:26:36.876Z" },
- { url = "https://files.pythonhosted.org/packages/36/11/ef0b23f4266349b6d5ccbd1a07b7adc998d5bce925792aa5d1ec33f593e3/fonttools-4.59.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:01158376b8a418a0bae9625c476cebfcfcb5e6761e9d243b219cd58341e7afbb", size = 5113777, upload-time = "2025-08-14T16:26:39.002Z" },
- { url = "https://files.pythonhosted.org/packages/d0/da/b398fe61ef433da0a0472cdb5d4399124f7581ffe1a31b6242c91477d802/fonttools-4.59.1-cp311-cp311-win32.whl", hash = "sha256:cf7c5089d37787387123f1cb8f1793a47c5e1e3d1e4e7bfbc1cc96e0f925eabe", size = 2215076, upload-time = "2025-08-14T16:26:41.196Z" },
- { url = "https://files.pythonhosted.org/packages/94/bd/e2624d06ab94e41c7c77727b2941f1baed7edb647e63503953e6888020c9/fonttools-4.59.1-cp311-cp311-win_amd64.whl", hash = "sha256:c866eef7a0ba320486ade6c32bfc12813d1a5db8567e6904fb56d3d40acc5116", size = 2262779, upload-time = "2025-08-14T16:26:43.483Z" },
- { url = "https://files.pythonhosted.org/packages/ac/fe/6e069cc4cb8881d164a9bd956e9df555bc62d3eb36f6282e43440200009c/fonttools-4.59.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:43ab814bbba5f02a93a152ee61a04182bb5809bd2bc3609f7822e12c53ae2c91", size = 2769172, upload-time = "2025-08-14T16:26:45.729Z" },
- { url = "https://files.pythonhosted.org/packages/b9/98/ec4e03f748fefa0dd72d9d95235aff6fef16601267f4a2340f0e16b9330f/fonttools-4.59.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4f04c3ffbfa0baafcbc550657cf83657034eb63304d27b05cff1653b448ccff6", size = 2337281, upload-time = "2025-08-14T16:26:47.921Z" },
- { url = "https://files.pythonhosted.org/packages/8b/b1/890360a7e3d04a30ba50b267aca2783f4c1364363797e892e78a4f036076/fonttools-4.59.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d601b153e51a5a6221f0d4ec077b6bfc6ac35bfe6c19aeaa233d8990b2b71726", size = 4909215, upload-time = "2025-08-14T16:26:49.682Z" },
- { url = "https://files.pythonhosted.org/packages/8a/ec/2490599550d6c9c97a44c1e36ef4de52d6acf742359eaa385735e30c05c4/fonttools-4.59.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c735e385e30278c54f43a0d056736942023c9043f84ee1021eff9fd616d17693", size = 4951958, upload-time = "2025-08-14T16:26:51.616Z" },
- { url = "https://files.pythonhosted.org/packages/d1/40/bd053f6f7634234a9b9805ff8ae4f32df4f2168bee23cafd1271ba9915a9/fonttools-4.59.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1017413cdc8555dce7ee23720da490282ab7ec1cf022af90a241f33f9a49afc4", size = 4894738, upload-time = "2025-08-14T16:26:53.836Z" },
- { url = "https://files.pythonhosted.org/packages/ac/a1/3cd12a010d288325a7cfcf298a84825f0f9c29b01dee1baba64edfe89257/fonttools-4.59.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5c6d8d773470a5107052874341ed3c487c16ecd179976d81afed89dea5cd7406", size = 5045983, upload-time = "2025-08-14T16:26:56.153Z" },
- { url = "https://files.pythonhosted.org/packages/a2/af/8a2c3f6619cc43cf87951405337cc8460d08a4e717bb05eaa94b335d11dc/fonttools-4.59.1-cp312-cp312-win32.whl", hash = "sha256:2a2d0d33307f6ad3a2086a95dd607c202ea8852fa9fb52af9b48811154d1428a", size = 2203407, upload-time = "2025-08-14T16:26:58.165Z" },
- { url = "https://files.pythonhosted.org/packages/8e/f2/a19b874ddbd3ebcf11d7e25188ef9ac3f68b9219c62263acb34aca8cde05/fonttools-4.59.1-cp312-cp312-win_amd64.whl", hash = "sha256:0b9e4fa7eaf046ed6ac470f6033d52c052481ff7a6e0a92373d14f556f298dc0", size = 2251561, upload-time = "2025-08-14T16:27:00.646Z" },
- { url = "https://files.pythonhosted.org/packages/0f/64/9d606e66d498917cd7a2ff24f558010d42d6fd4576d9dd57f0bd98333f5a/fonttools-4.59.1-py3-none-any.whl", hash = "sha256:647db657073672a8330608970a984d51573557f328030566521bc03415535042", size = 1130094, upload-time = "2025-08-14T16:28:12.048Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/53/742fcd750ae0bdc74de4c0ff923111199cc2f90a4ee87aaddad505b6f477/fonttools-4.59.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:511946e8d7ea5c0d6c7a53c4cb3ee48eda9ab9797cd9bf5d95829a398400354f", size = 2774961, upload-time = "2025-08-27T16:38:47.536Z" },
+ { url = "https://files.pythonhosted.org/packages/57/2a/976f5f9fa3b4dd911dc58d07358467bec20e813d933bc5d3db1a955dd456/fonttools-4.59.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8e5e2682cf7be766d84f462ba8828d01e00c8751a8e8e7ce12d7784ccb69a30d", size = 2344690, upload-time = "2025-08-27T16:38:49.723Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/b7eefc274fcf370911e292e95565c8253b0b87c82a53919ab3c795a4f50e/fonttools-4.59.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5729e12a982dba3eeae650de48b06f3b9ddb51e9aee2fcaf195b7d09a96250e2", size = 5026910, upload-time = "2025-08-27T16:38:51.904Z" },
+ { url = "https://files.pythonhosted.org/packages/69/95/864726eaa8f9d4e053d0c462e64d5830ec7c599cbdf1db9e40f25ca3972e/fonttools-4.59.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c52694eae5d652361d59ecdb5a2246bff7cff13b6367a12da8499e9df56d148d", size = 4971031, upload-time = "2025-08-27T16:38:53.676Z" },
+ { url = "https://files.pythonhosted.org/packages/24/4c/b8c4735ebdea20696277c70c79e0de615dbe477834e5a7c2569aa1db4033/fonttools-4.59.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f1bbc23ba1312bd8959896f46f667753b90216852d2a8cfa2d07e0cb234144", size = 5006112, upload-time = "2025-08-27T16:38:55.69Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/23/f9ea29c292aa2fc1ea381b2e5621ac436d5e3e0a5dee24ffe5404e58eae8/fonttools-4.59.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a1bfe5378962825dabe741720885e8b9ae9745ec7ecc4a5ec1f1ce59a6062bf", size = 5117671, upload-time = "2025-08-27T16:38:58.984Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/07/cfea304c555bf06e86071ff2a3916bc90f7c07ec85b23bab758d4908c33d/fonttools-4.59.2-cp311-cp311-win32.whl", hash = "sha256:e937790f3c2c18a1cbc7da101550a84319eb48023a715914477d2e7faeaba570", size = 2218157, upload-time = "2025-08-27T16:39:00.75Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/de/35d839aa69db737a3f9f3a45000ca24721834d40118652a5775d5eca8ebb/fonttools-4.59.2-cp311-cp311-win_amd64.whl", hash = "sha256:9836394e2f4ce5f9c0a7690ee93bd90aa1adc6b054f1a57b562c5d242c903104", size = 2265846, upload-time = "2025-08-27T16:39:02.453Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/3d/1f45db2df51e7bfa55492e8f23f383d372200be3a0ded4bf56a92753dd1f/fonttools-4.59.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82906d002c349cad647a7634b004825a7335f8159d0d035ae89253b4abf6f3ea", size = 2769711, upload-time = "2025-08-27T16:39:04.423Z" },
+ { url = "https://files.pythonhosted.org/packages/29/df/cd236ab32a8abfd11558f296e064424258db5edefd1279ffdbcfd4fd8b76/fonttools-4.59.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a10c1bd7644dc58f8862d8ba0cf9fb7fef0af01ea184ba6ce3f50ab7dfe74d5a", size = 2340225, upload-time = "2025-08-27T16:39:06.143Z" },
+ { url = "https://files.pythonhosted.org/packages/98/12/b6f9f964fe6d4b4dd4406bcbd3328821c3de1f909ffc3ffa558fe72af48c/fonttools-4.59.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:738f31f23e0339785fd67652a94bc69ea49e413dfdb14dcb8c8ff383d249464e", size = 4912766, upload-time = "2025-08-27T16:39:08.138Z" },
+ { url = "https://files.pythonhosted.org/packages/73/78/82bde2f2d2c306ef3909b927363170b83df96171f74e0ccb47ad344563cd/fonttools-4.59.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ec99f9bdfee9cdb4a9172f9e8fd578cce5feb231f598909e0aecf5418da4f25", size = 4955178, upload-time = "2025-08-27T16:39:10.094Z" },
+ { url = "https://files.pythonhosted.org/packages/92/77/7de766afe2d31dda8ee46d7e479f35c7d48747e558961489a2d6e3a02bd4/fonttools-4.59.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0476ea74161322e08c7a982f83558a2b81b491509984523a1a540baf8611cc31", size = 4897898, upload-time = "2025-08-27T16:39:12.087Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/77/ce0e0b905d62a06415fda9f2b2e109a24a5db54a59502b769e9e297d2242/fonttools-4.59.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95922a922daa1f77cc72611747c156cfb38030ead72436a2c551d30ecef519b9", size = 5049144, upload-time = "2025-08-27T16:39:13.84Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/ea/870d93aefd23fff2e07cbeebdc332527868422a433c64062c09d4d5e7fe6/fonttools-4.59.2-cp312-cp312-win32.whl", hash = "sha256:39ad9612c6a622726a6a130e8ab15794558591f999673f1ee7d2f3d30f6a3e1c", size = 2206473, upload-time = "2025-08-27T16:39:15.854Z" },
+ { url = "https://files.pythonhosted.org/packages/61/c4/e44bad000c4a4bb2e9ca11491d266e857df98ab6d7428441b173f0fe2517/fonttools-4.59.2-cp312-cp312-win_amd64.whl", hash = "sha256:980fd7388e461b19a881d35013fec32c713ffea1fc37aef2f77d11f332dfd7da", size = 2254706, upload-time = "2025-08-27T16:39:17.893Z" },
+ { url = "https://files.pythonhosted.org/packages/65/a4/d2f7be3c86708912c02571db0b550121caab8cd88a3c0aacb9cfa15ea66e/fonttools-4.59.2-py3-none-any.whl", hash = "sha256:8bd0f759020e87bb5d323e6283914d9bf4ae35a7307dafb2cbd1e379e720ad37", size = 1132315, upload-time = "2025-08-27T16:40:28.984Z" },
]
[[package]]
@@ -1307,6 +1309,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/c9/751b6401887f4b50f9307cc1e53d287b3dc77c375c126aeb6335aff73ccb/HyperPyYAML-1.2.2-py3-none-any.whl", hash = "sha256:3c5864bdc8864b2f0fbd7bc495e7e8fdf2dfd5dd80116f72da27ca96a128bdeb", size = 16118, upload-time = "2023-09-21T14:45:25.101Z" },
]
+[[package]]
+name = "icalendar"
+version = "6.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/08/13/e5899c916dcf1343ea65823eb7278d3e1a1d679f383f6409380594b5f322/icalendar-6.3.1.tar.gz", hash = "sha256:a697ce7b678072941e519f2745704fc29d78ef92a2dc53d9108ba6a04aeba466", size = 177169, upload-time = "2025-05-20T07:42:50.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6c/25/b5fc00e85d2dfaf5c806ac8b5f1de072fa11630c5b15b4ae5bbc228abd51/icalendar-6.3.1-py3-none-any.whl", hash = "sha256:7ea1d1b212df685353f74cdc6ec9646bf42fa557d1746ea645ce8779fdfbecdd", size = 242349, upload-time = "2025-05-20T07:42:48.589Z" },
+]
+
[[package]]
name = "idna"
version = "3.10"
@@ -1549,7 +1564,7 @@ wheels = [
[[package]]
name = "lightning"
-version = "2.5.3"
+version = "2.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fsspec", extra = ["http"] },
@@ -1563,9 +1578,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/01/80/dddb5a382aa0ff18045aee6491f81e40371102cb05da2ad5a8436a51c475/lightning-2.5.3.tar.gz", hash = "sha256:4ed3e12369a1e0f928beecf5c9f5efdabda60a9216057954851e2d89f1abecde", size = 636577, upload-time = "2025-08-13T20:29:32.361Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/dd/86bb3bebadcdbc6e6e5a63657f0a03f74cd065b5ea965896679f76fec0b4/lightning-2.5.5.tar.gz", hash = "sha256:4d3d66c5b1481364a7e6a1ce8ddde1777a04fa740a3145ec218a9941aed7dd30", size = 640770, upload-time = "2025-09-05T16:01:21.026Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/00/6b/00e9c2b03a449c21d7a4d73a7104ac94f56c37a1e6eae77b1c702d8dddf0/lightning-2.5.3-py3-none-any.whl", hash = "sha256:c551111fda0db0bce267791f9a90cd4f9cf94bc327d36348af0ef79ec752d666", size = 824181, upload-time = "2025-08-13T20:29:30.244Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/d0/4b4fbafc3b18df91207a6e46782d9fd1905f9f45cb2c3b8dfbb239aef781/lightning-2.5.5-py3-none-any.whl", hash = "sha256:69eb248beadd7b600bf48eff00a0ec8af171ec7a678d23787c4aedf12e225e8f", size = 828490, upload-time = "2025-09-05T16:01:17.845Z" },
]
[[package]]
@@ -1865,19 +1880,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/24/8497595be04a8a0209536e9ce70d4132f8f8e001986f4c700414b3777758/llama_parse-0.6.43-py3-none-any.whl", hash = "sha256:fe435309638c4fdec4fec31f97c5031b743c92268962d03b99bd76704f566c32", size = 4944, upload-time = "2025-07-08T18:20:57.089Z" },
]
-[[package]]
-name = "loguru"
-version = "0.7.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "win32-setctime", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
-]
-
[[package]]
name = "mako"
version = "1.3.10"
@@ -1944,7 +1946,7 @@ wheels = [
[[package]]
name = "matplotlib"
-version = "3.10.5"
+version = "3.10.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "contourpy" },
@@ -1957,25 +1959,25 @@ dependencies = [
{ name = "pyparsing" },
{ name = "python-dateutil" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/43/91/f2939bb60b7ebf12478b030e0d7f340247390f402b3b189616aad790c366/matplotlib-3.10.5.tar.gz", hash = "sha256:352ed6ccfb7998a00881692f38b4ca083c691d3e275b4145423704c34c909076", size = 34804044, upload-time = "2025-07-31T18:09:33.805Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a0/59/c3e6453a9676ffba145309a73c462bb407f4400de7de3f2b41af70720a3c/matplotlib-3.10.6.tar.gz", hash = "sha256:ec01b645840dd1996df21ee37f208cd8ba57644779fa20464010638013d3203c", size = 34804264, upload-time = "2025-08-30T00:14:25.137Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/aa/c7/1f2db90a1d43710478bb1e9b57b162852f79234d28e4f48a28cc415aa583/matplotlib-3.10.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:dcfc39c452c6a9f9028d3e44d2d721484f665304857188124b505b2c95e1eecf", size = 8239216, upload-time = "2025-07-31T18:07:51.947Z" },
- { url = "https://files.pythonhosted.org/packages/82/6d/ca6844c77a4f89b1c9e4d481c412e1d1dbabf2aae2cbc5aa2da4a1d6683e/matplotlib-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:903352681b59f3efbf4546985142a9686ea1d616bb054b09a537a06e4b892ccf", size = 8102130, upload-time = "2025-07-31T18:07:53.65Z" },
- { url = "https://files.pythonhosted.org/packages/1d/1e/5e187a30cc673a3e384f3723e5f3c416033c1d8d5da414f82e4e731128ea/matplotlib-3.10.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:080c3676a56b8ee1c762bcf8fca3fe709daa1ee23e6ef06ad9f3fc17332f2d2a", size = 8666471, upload-time = "2025-07-31T18:07:55.304Z" },
- { url = "https://files.pythonhosted.org/packages/03/c0/95540d584d7d645324db99a845ac194e915ef75011a0d5e19e1b5cee7e69/matplotlib-3.10.5-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b4984d5064a35b6f66d2c11d668565f4389b1119cc64db7a4c1725bc11adffc", size = 9500518, upload-time = "2025-07-31T18:07:57.199Z" },
- { url = "https://files.pythonhosted.org/packages/ba/2e/e019352099ea58b4169adb9c6e1a2ad0c568c6377c2b677ee1f06de2adc7/matplotlib-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3967424121d3a46705c9fa9bdb0931de3228f13f73d7bb03c999c88343a89d89", size = 9552372, upload-time = "2025-07-31T18:07:59.41Z" },
- { url = "https://files.pythonhosted.org/packages/b7/81/3200b792a5e8b354f31f4101ad7834743ad07b6d620259f2059317b25e4d/matplotlib-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:33775bbeb75528555a15ac29396940128ef5613cf9a2d31fb1bfd18b3c0c0903", size = 8100634, upload-time = "2025-07-31T18:08:01.801Z" },
- { url = "https://files.pythonhosted.org/packages/52/46/a944f6f0c1f5476a0adfa501969d229ce5ae60cf9a663be0e70361381f89/matplotlib-3.10.5-cp311-cp311-win_arm64.whl", hash = "sha256:c61333a8e5e6240e73769d5826b9a31d8b22df76c0778f8480baf1b4b01c9420", size = 7978880, upload-time = "2025-07-31T18:08:03.407Z" },
- { url = "https://files.pythonhosted.org/packages/66/1e/c6f6bcd882d589410b475ca1fc22e34e34c82adff519caf18f3e6dd9d682/matplotlib-3.10.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:00b6feadc28a08bd3c65b2894f56cf3c94fc8f7adcbc6ab4516ae1e8ed8f62e2", size = 8253056, upload-time = "2025-07-31T18:08:05.385Z" },
- { url = "https://files.pythonhosted.org/packages/53/e6/d6f7d1b59413f233793dda14419776f5f443bcccb2dfc84b09f09fe05dbe/matplotlib-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee98a5c5344dc7f48dc261b6ba5d9900c008fc12beb3fa6ebda81273602cc389", size = 8110131, upload-time = "2025-07-31T18:08:07.293Z" },
- { url = "https://files.pythonhosted.org/packages/66/2b/bed8a45e74957549197a2ac2e1259671cd80b55ed9e1fe2b5c94d88a9202/matplotlib-3.10.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a17e57e33de901d221a07af32c08870ed4528db0b6059dce7d7e65c1122d4bea", size = 8669603, upload-time = "2025-07-31T18:08:09.064Z" },
- { url = "https://files.pythonhosted.org/packages/7e/a7/315e9435b10d057f5e52dfc603cd353167ae28bb1a4e033d41540c0067a4/matplotlib-3.10.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97b9d6443419085950ee4a5b1ee08c363e5c43d7176e55513479e53669e88468", size = 9508127, upload-time = "2025-07-31T18:08:10.845Z" },
- { url = "https://files.pythonhosted.org/packages/7f/d9/edcbb1f02ca99165365d2768d517898c22c6040187e2ae2ce7294437c413/matplotlib-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ceefe5d40807d29a66ae916c6a3915d60ef9f028ce1927b84e727be91d884369", size = 9566926, upload-time = "2025-07-31T18:08:13.186Z" },
- { url = "https://files.pythonhosted.org/packages/3b/d9/6dd924ad5616c97b7308e6320cf392c466237a82a2040381163b7500510a/matplotlib-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:c04cba0f93d40e45b3c187c6c52c17f24535b27d545f757a2fffebc06c12b98b", size = 8107599, upload-time = "2025-07-31T18:08:15.116Z" },
- { url = "https://files.pythonhosted.org/packages/0e/f3/522dc319a50f7b0279fbe74f86f7a3506ce414bc23172098e8d2bdf21894/matplotlib-3.10.5-cp312-cp312-win_arm64.whl", hash = "sha256:a41bcb6e2c8e79dc99c5511ae6f7787d2fb52efd3d805fff06d5d4f667db16b2", size = 7978173, upload-time = "2025-07-31T18:08:21.518Z" },
- { url = "https://files.pythonhosted.org/packages/dc/d6/e921be4e1a5f7aca5194e1f016cb67ec294548e530013251f630713e456d/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:160e125da27a749481eaddc0627962990f6029811dbeae23881833a011a0907f", size = 8233224, upload-time = "2025-07-31T18:09:27.512Z" },
- { url = "https://files.pythonhosted.org/packages/ec/74/a2b9b04824b9c349c8f1b2d21d5af43fa7010039427f2b133a034cb09e59/matplotlib-3.10.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ac3d50760394d78a3c9be6b28318fe22b494c4fcf6407e8fd4794b538251899b", size = 8098539, upload-time = "2025-07-31T18:09:29.629Z" },
- { url = "https://files.pythonhosted.org/packages/fc/66/cd29ebc7f6c0d2a15d216fb572573e8fc38bd5d6dec3bd9d7d904c0949f7/matplotlib-3.10.5-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c49465bf689c4d59d174d0c7795fb42a21d4244d11d70e52b8011987367ac61", size = 8672192, upload-time = "2025-07-31T18:09:31.407Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/5d3665aa44c49005aaacaa68ddea6fcb27345961cd538a98bb0177934ede/matplotlib-3.10.6-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:905b60d1cb0ee604ce65b297b61cf8be9f4e6cfecf95a3fe1c388b5266bc8f4f", size = 8257527, upload-time = "2025-08-30T00:12:45.31Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/af/30ddefe19ca67eebd70047dabf50f899eaff6f3c5e6a1a7edaecaf63f794/matplotlib-3.10.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7bac38d816637343e53d7185d0c66677ff30ffb131044a81898b5792c956ba76", size = 8119583, upload-time = "2025-08-30T00:12:47.236Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/29/4a8650a3dcae97fa4f375d46efcb25920d67b512186f8a6788b896062a81/matplotlib-3.10.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:942a8de2b5bfff1de31d95722f702e2966b8a7e31f4e68f7cd963c7cd8861cf6", size = 8692682, upload-time = "2025-08-30T00:12:48.781Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/d3/b793b9cb061cfd5d42ff0f69d1822f8d5dbc94e004618e48a97a8373179a/matplotlib-3.10.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3276c85370bc0dfca051ec65c5817d1e0f8f5ce1b7787528ec8ed2d524bbc2f", size = 9521065, upload-time = "2025-08-30T00:12:50.602Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/c5/53de5629f223c1c66668d46ac2621961970d21916a4bc3862b174eb2a88f/matplotlib-3.10.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9df5851b219225731f564e4b9e7f2ac1e13c9e6481f941b5631a0f8e2d9387ce", size = 9576888, upload-time = "2025-08-30T00:12:52.92Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/8e/0a18d6d7d2d0a2e66585032a760d13662e5250c784d53ad50434e9560991/matplotlib-3.10.6-cp311-cp311-win_amd64.whl", hash = "sha256:abb5d9478625dd9c9eb51a06d39aae71eda749ae9b3138afb23eb38824026c7e", size = 8115158, upload-time = "2025-08-30T00:12:54.863Z" },
+ { url = "https://files.pythonhosted.org/packages/07/b3/1a5107bb66c261e23b9338070702597a2d374e5aa7004b7adfc754fbed02/matplotlib-3.10.6-cp311-cp311-win_arm64.whl", hash = "sha256:886f989ccfae63659183173bb3fced7fd65e9eb793c3cc21c273add368536951", size = 7992444, upload-time = "2025-08-30T00:12:57.067Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/1a/7042f7430055d567cc3257ac409fcf608599ab27459457f13772c2d9778b/matplotlib-3.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31ca662df6a80bd426f871105fdd69db7543e28e73a9f2afe80de7e531eb2347", size = 8272404, upload-time = "2025-08-30T00:12:59.112Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/5d/1d5f33f5b43f4f9e69e6a5fe1fb9090936ae7bc8e2ff6158e7a76542633b/matplotlib-3.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1678bb61d897bb4ac4757b5ecfb02bfb3fddf7f808000fb81e09c510712fda75", size = 8128262, upload-time = "2025-08-30T00:13:01.141Z" },
+ { url = "https://files.pythonhosted.org/packages/67/c3/135fdbbbf84e0979712df58e5e22b4f257b3f5e52a3c4aacf1b8abec0d09/matplotlib-3.10.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:56cd2d20842f58c03d2d6e6c1f1cf5548ad6f66b91e1e48f814e4fb5abd1cb95", size = 8697008, upload-time = "2025-08-30T00:13:03.24Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/be/c443ea428fb2488a3ea7608714b1bd85a82738c45da21b447dc49e2f8e5d/matplotlib-3.10.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:662df55604a2f9a45435566d6e2660e41efe83cd94f4288dfbf1e6d1eae4b0bb", size = 9530166, upload-time = "2025-08-30T00:13:05.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/35/48441422b044d74034aea2a3e0d1a49023f12150ebc58f16600132b9bbaf/matplotlib-3.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:08f141d55148cd1fc870c3387d70ca4df16dee10e909b3b038782bd4bda6ea07", size = 9593105, upload-time = "2025-08-30T00:13:08.356Z" },
+ { url = "https://files.pythonhosted.org/packages/45/c3/994ef20eb4154ab84cc08d033834555319e4af970165e6c8894050af0b3c/matplotlib-3.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:590f5925c2d650b5c9d813c5b3b5fc53f2929c3f8ef463e4ecfa7e052044fb2b", size = 8122784, upload-time = "2025-08-30T00:13:10.367Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b8/5c85d9ae0e40f04e71bedb053aada5d6bab1f9b5399a0937afb5d6b02d98/matplotlib-3.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:f44c8d264a71609c79a78d50349e724f5d5fc3684ead7c2a473665ee63d868aa", size = 7992823, upload-time = "2025-08-30T00:13:12.24Z" },
+ { url = "https://files.pythonhosted.org/packages/12/bb/02c35a51484aae5f49bd29f091286e7af5f3f677a9736c58a92b3c78baeb/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f2d684c3204fa62421bbf770ddfebc6b50130f9cad65531eeba19236d73bb488", size = 8252296, upload-time = "2025-08-30T00:14:19.49Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/85/41701e3092005aee9a2445f5ee3904d9dbd4a7df7a45905ffef29b7ef098/matplotlib-3.10.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:6f4a69196e663a41d12a728fab8751177215357906436804217d6d9cf0d4d6cf", size = 8116749, upload-time = "2025-08-30T00:14:21.344Z" },
+ { url = "https://files.pythonhosted.org/packages/16/53/8d8fa0ea32a8c8239e04d022f6c059ee5e1b77517769feccd50f1df43d6d/matplotlib-3.10.6-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d6ca6ef03dfd269f4ead566ec6f3fb9becf8dab146fb999022ed85ee9f6b3eb", size = 8693933, upload-time = "2025-08-30T00:14:22.942Z" },
]
[[package]]
@@ -2176,7 +2178,7 @@ wheels = [
[[package]]
name = "optuna"
-version = "4.4.0"
+version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
@@ -2187,9 +2189,9 @@ dependencies = [
{ name = "sqlalchemy" },
{ name = "tqdm" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a5/e0/b303190ae8032d12f320a24c42af04038bacb1f3b17ede354dd1044a5642/optuna-4.4.0.tar.gz", hash = "sha256:a9029f6a92a1d6c8494a94e45abd8057823b535c2570819072dbcdc06f1c1da4", size = 467708, upload-time = "2025-06-16T05:13:00.024Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/bcd1e5500de6ec794c085a277e5b624e60b4fac1790681d7cdbde25b93a2/optuna-4.5.0.tar.gz", hash = "sha256:264844da16dad744dea295057d8bc218646129c47567d52c35a201d9f99942ba", size = 472338, upload-time = "2025-08-18T06:49:22.402Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/5e/068798a8c7087863e7772e9363a880ab13fe55a5a7ede8ec42fab8a1acbb/optuna-4.4.0-py3-none-any.whl", hash = "sha256:fad8d9c5d5af993ae1280d6ce140aecc031c514a44c3b639d8c8658a8b7920ea", size = 395949, upload-time = "2025-06-16T05:12:58.37Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/12/cba81286cbaf0f0c3f0473846cfd992cb240bdcea816bf2ef7de8ed0f744/optuna-4.5.0-py3-none-any.whl", hash = "sha256:5b8a783e84e448b0742501bc27195344a28d2c77bd2feef5b558544d954851b0", size = 400872, upload-time = "2025-08-18T06:49:20.697Z" },
]
[[package]]
@@ -2937,7 +2939,7 @@ wheels = [
[[package]]
name = "pytorch-lightning"
-version = "2.5.3"
+version = "2.5.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fsspec", extra = ["http"] },
@@ -2950,14 +2952,14 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/32/a8/31fe79bf96dab33cee5537ed6f08230ed6f032834bb4ff529cc487fb40e8/pytorch_lightning-2.5.3.tar.gz", hash = "sha256:65f4eee774ee1adba181aacacffb9f677fe5c5f9fd3d01a95f603403f940be6a", size = 639897, upload-time = "2025-08-13T20:29:39.161Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/16/78/bce84aab9a5b3b2e9d087d4f1a6be9b481adbfaac4903bc9daaaf09d49a3/pytorch_lightning-2.5.5.tar.gz", hash = "sha256:d6fc8173d1d6e49abfd16855ea05d2eb2415e68593f33d43e59028ecb4e64087", size = 643703, upload-time = "2025-09-05T16:01:18.313Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/a2/5f2b7b40ec5213db5282e98dd32fd419fe5b73b5b53895dfff56fe12fed0/pytorch_lightning-2.5.3-py3-none-any.whl", hash = "sha256:7476bd36282d9253dda175b9263b07942489d70ad90bbd1bc0a59c46e012f353", size = 828186, upload-time = "2025-08-13T20:29:37.41Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f6/99a5c66478f469598dee25b0e29b302b5bddd4e03ed0da79608ac964056e/pytorch_lightning-2.5.5-py3-none-any.whl", hash = "sha256:0b533991df2353c0c6ea9ca10a7d0728b73631fd61f5a15511b19bee2aef8af0", size = 832431, upload-time = "2025-09-05T16:01:16.234Z" },
]
[[package]]
name = "pytorch-metric-learning"
-version = "2.8.1"
+version = "2.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
@@ -2966,9 +2968,9 @@ dependencies = [
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" },
{ name = "tqdm" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/78/94/1bfb2c3eaf195b2d72912b65b3d417f2d9ac22491563eca360d453512c59/pytorch-metric-learning-2.8.1.tar.gz", hash = "sha256:fcc4d3b4a805e5fce25fb2e67505c47ba6fea0563fc09c5655ea1f08d1e8ed93", size = 83117, upload-time = "2024-12-11T19:21:15.982Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9b/80/6e61b1a91debf4c1b47d441f9a9d7fe2aabcdd9575ed70b2811474eb95c3/pytorch-metric-learning-2.9.0.tar.gz", hash = "sha256:27a626caf5e2876a0fd666605a78cb67ef7597e25d7a68c18053dd503830701f", size = 84530, upload-time = "2025-08-17T17:11:19.501Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/60/15/eee4e24c3f5a63b3e73692ff79766a66cab8844e24f5912be29350937592/pytorch_metric_learning-2.8.1-py3-none-any.whl", hash = "sha256:aba6da0508d29ee9661a67fbfee911cdf62e65fc07e404b167d82871ca7e3e88", size = 125923, upload-time = "2024-12-11T19:21:13.448Z" },
+ { url = "https://files.pythonhosted.org/packages/46/7d/73ef5052f57b7720cad00e16598db3592a5ef4826745ffca67a2f085d4dc/pytorch_metric_learning-2.9.0-py3-none-any.whl", hash = "sha256:d51646006dc87168f00cf954785db133a4c5aac81253877248737aa42ef6432a", size = 127801, upload-time = "2025-08-17T17:11:18.185Z" },
]
[[package]]
@@ -3104,10 +3106,10 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "fastapi-pagination" },
{ name = "httpx" },
+ { name = "icalendar" },
{ name = "jsonschema" },
{ name = "llama-index" },
{ name = "llama-index-llms-openai-like" },
- { name = "loguru" },
{ name = "nltk" },
{ name = "openai" },
{ name = "prometheus-fastapi-instrumentator" },
@@ -3180,10 +3182,10 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.100.1" },
{ name = "fastapi-pagination", specifier = ">=0.12.6" },
{ name = "httpx", specifier = ">=0.24.1" },
+ { name = "icalendar", specifier = ">=6.0.0" },
{ name = "jsonschema", specifier = ">=4.23.0" },
{ name = "llama-index", specifier = ">=0.12.52" },
{ name = "llama-index-llms-openai-like", specifier = ">=0.4.0" },
- { name = "loguru", specifier = ">=0.7.0" },
{ name = "nltk", specifier = ">=3.8.1" },
{ name = "openai", specifier = ">=1.59.7" },
{ name = "prometheus-fastapi-instrumentator", specifier = ">=6.1.0" },
@@ -3427,14 +3429,14 @@ wheels = [
[[package]]
name = "ruamel-yaml"
-version = "0.18.14"
+version = "0.18.15"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ruamel-yaml-clib", marker = "platform_python_implementation == 'CPython'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/39/87/6da0df742a4684263261c253f00edd5829e6aca970fff69e75028cccc547/ruamel.yaml-0.18.14.tar.gz", hash = "sha256:7227b76aaec364df15936730efbf7d72b30c0b79b1d578bbb8e3dcb2d81f52b7", size = 145511, upload-time = "2025-06-09T08:51:09.828Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/3e/db/f3950f5e5031b618aae9f423a39bf81a55c148aecd15a34527898e752cf4/ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700", size = 146865, upload-time = "2025-08-19T11:15:10.694Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/af/6d/6fe4805235e193aad4aaf979160dd1f3c487c57d48b810c816e6e842171b/ruamel.yaml-0.18.14-py3-none-any.whl", hash = "sha256:710ff198bb53da66718c7db27eec4fbcc9aa6ca7204e4c1df2f282b6fe5eb6b2", size = 118570, upload-time = "2025-06-09T08:51:06.348Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/e5/f2a0621f1781b76a38194acae72f01e37b1941470407345b6e8653ad7640/ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701", size = 119702, upload-time = "2025-08-19T11:15:07.696Z" },
]
[[package]]
@@ -3621,7 +3623,7 @@ wheels = [
[[package]]
name = "silero-vad"
-version = "5.1.2"
+version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "onnxruntime" },
@@ -3630,9 +3632,9 @@ dependencies = [
{ name = "torchaudio", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'darwin')" },
{ name = "torchaudio", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/b4/d0311b2e6220a11f8f4699f4a278cb088131573286cdfe804c87c7eb5123/silero_vad-5.1.2.tar.gz", hash = "sha256:c442971160026d2d7aa0ad83f0c7ee86c89797a65289fe625c8ea59fc6fb828d", size = 5098526, upload-time = "2024-10-09T09:50:47.019Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/79/ff5b13ca491a2eef2a43cd989ac9a87fa2131c246d467d909f2568c56955/silero_vad-6.0.0.tar.gz", hash = "sha256:4d202cb662112d9cba0e3fbc9f2c67e2e265c853f319adf20e348d108c797b76", size = 14567206, upload-time = "2025-08-26T07:10:02.571Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/98/f7/5ae11d13fbb733cd3bfd7ff1c3a3902e6f55437df4b72307c1f168146268/silero_vad-5.1.2-py3-none-any.whl", hash = "sha256:93b41953d7774b165407fda6b533c119c5803864e367d5034dc626c82cfdf661", size = 5026737, upload-time = "2024-10-09T09:50:44.355Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/6a/a0a024878a1933a2326c42a3ce24fff6c0bf4882655f156c960ba50c2ed4/silero_vad-6.0.0-py3-none-any.whl", hash = "sha256:37d29be8944d2a2e6f1cc38a066076f13e78e6fc1b567a1beddcca72096f077f", size = 6119146, upload-time = "2025-08-26T07:10:00.637Z" },
]
[[package]]
@@ -3940,12 +3942,14 @@ name = "torch"
version = "2.8.0+cpu"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
- "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'",
+ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'",
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version >= '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'",
- "python_full_version < '3.12' and platform_python_implementation == 'PyPy'",
+ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
+ "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'",
"(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version < '3.12' and platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux'",
+ "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
]
dependencies = [
{ name = "filelock", marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" },
@@ -4029,10 +4033,12 @@ name = "torchaudio"
version = "2.8.0+cpu"
source = { registry = "https://download.pytorch.org/whl/cpu" }
resolution-markers = [
- "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'",
+ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'",
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version >= '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
- "python_full_version < '3.12' and platform_python_implementation == 'PyPy'",
+ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
+ "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'",
"(python_full_version < '3.12' and platform_machine != 'aarch64' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'CPython' and platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (python_full_version < '3.12' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin' and sys_platform != 'linux')",
+ "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
]
dependencies = [
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (platform_python_implementation == 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'CPython' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux')" },
@@ -4046,7 +4052,7 @@ wheels = [
[[package]]
name = "torchmetrics"
-version = "1.8.1"
+version = "1.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lightning-utilities" },
@@ -4055,9 +4061,9 @@ dependencies = [
{ name = "torch", version = "2.8.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation != 'PyPy' and sys_platform == 'darwin'" },
{ name = "torch", version = "2.8.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "platform_python_implementation == 'PyPy' or sys_platform != 'darwin'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/78/1f/2cd9eb8f3390c3ec4693ac0871913d4b468964b3833638e4091a70817e0a/torchmetrics-1.8.1.tar.gz", hash = "sha256:04ca021105871637c5d34d0a286b3ab665a1e3d2b395e561f14188a96e862fdb", size = 580373, upload-time = "2025-08-07T20:44:44.631Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679, upload-time = "2025-09-03T14:00:54.077Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8f/59/5c1c1cb08c494621901cf549a543f87143019fac1e6dd191eb4630bbc8fb/torchmetrics-1.8.1-py3-none-any.whl", hash = "sha256:2437501351e0da3d294c71210ce8139b9c762b5e20604f7a051a725443db8f4b", size = 982961, upload-time = "2025-08-07T20:44:42.608Z" },
+ { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161, upload-time = "2025-09-03T14:00:51.921Z" },
]
[[package]]
@@ -4209,8 +4215,10 @@ name = "vcrpy"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
- "python_full_version >= '3.12' and platform_python_implementation == 'PyPy'",
- "python_full_version < '3.12' and platform_python_implementation == 'PyPy'",
+ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'",
+ "python_full_version >= '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
+ "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform != 'darwin'",
+ "python_full_version < '3.12' and platform_python_implementation == 'PyPy' and sys_platform == 'darwin'",
]
dependencies = [
{ name = "pyyaml", marker = "platform_python_implementation == 'PyPy'" },
@@ -4344,15 +4352,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/ed/aad7e0f5a462d679f7b4d2e0d8502c3096740c883b5bbed5103146480937/webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7", size = 19802, upload-time = "2024-05-30T13:40:14.661Z" },
]
-[[package]]
-name = "win32-setctime"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
-]
-
[[package]]
name = "wrapt"
version = "1.17.2"
diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx
new file mode 100644
index 00000000..1fa97692
--- /dev/null
+++ b/www/app/(app)/rooms/_components/ICSSettings.tsx
@@ -0,0 +1,343 @@
+import {
+ VStack,
+ HStack,
+ Field,
+ Input,
+ Select,
+ Checkbox,
+ Button,
+ Text,
+ Badge,
+ createListCollection,
+ Spinner,
+ Box,
+ IconButton,
+} from "@chakra-ui/react";
+import { useState, useEffect, useRef } from "react";
+import { LuRefreshCw, LuCopy, LuCheck } from "react-icons/lu";
+import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
+import { useRoomIcsSync, useRoomIcsStatus } from "../../../lib/apiHooks";
+import { toaster } from "../../../components/ui/toaster";
+import { roomAbsoluteUrl } from "../../../lib/routesClient";
+import {
+ assertExists,
+ assertExistsAndNonEmptyString,
+ NonEmptyString,
+ parseNonEmptyString,
+} from "../../../lib/utils";
+
+interface ICSSettingsProps {
+ roomName: NonEmptyString;
+ icsUrl?: string;
+ icsEnabled?: boolean;
+ icsFetchInterval?: number;
+ icsLastSync?: string;
+ icsLastEtag?: string;
+ onChange: (settings: Partial) => void;
+ isOwner?: boolean;
+ isEditing?: boolean;
+}
+
+export interface ICSSettingsData {
+ ics_url: string;
+ ics_enabled: boolean;
+ ics_fetch_interval: number;
+}
+
+const fetchIntervalOptions = [
+ { label: "1 minute", value: "1" },
+ { label: "5 minutes", value: "5" },
+ { label: "10 minutes", value: "10" },
+ { label: "30 minutes", value: "30" },
+ { label: "1 hour", value: "60" },
+];
+
+export default function ICSSettings({
+ roomName,
+ icsUrl = "",
+ icsEnabled = false,
+ icsFetchInterval = 5,
+ icsLastSync,
+ icsLastEtag,
+ onChange,
+ isOwner = true,
+ isEditing = false,
+}: ICSSettingsProps) {
+ const [syncStatus, setSyncStatus] = useState<
+ "idle" | "syncing" | "success" | "error"
+ >("idle");
+ const [syncMessage, setSyncMessage] = useState("");
+ const [syncResult, setSyncResult] = useState<{
+ eventsFound: number;
+ totalEvents: number;
+ eventsCreated: number;
+ eventsUpdated: number;
+ } | null>(null);
+ const [justCopied, setJustCopied] = useState(false);
+ const roomUrlInputRef = useRef(null);
+
+ const syncMutation = useRoomIcsSync();
+
+ const fetchIntervalCollection = createListCollection({
+ items: fetchIntervalOptions,
+ });
+
+ const handleCopyRoomUrl = async () => {
+ try {
+ await navigator.clipboard.writeText(
+ roomAbsoluteUrl(assertExistsAndNonEmptyString(roomName)),
+ );
+ setJustCopied(true);
+
+ toaster
+ .create({
+ placement: "top",
+ duration: 3000,
+ render: ({ dismiss }) => (
+
+
+ Room URL copied to clipboard!
+
+ ),
+ })
+ .then(() => {});
+
+ setTimeout(() => {
+ setJustCopied(false);
+ }, 2000);
+ } catch (err) {
+ console.error("Failed to copy room url:", err);
+ }
+ };
+
+ const handleRoomUrlClick = () => {
+ if (roomUrlInputRef.current) {
+ roomUrlInputRef.current.select();
+ handleCopyRoomUrl();
+ }
+ };
+
+ // Clear sync results when dialog closes
+ useEffect(() => {
+ if (!isEditing) {
+ setSyncStatus("idle");
+ setSyncResult(null);
+ setSyncMessage("");
+ }
+ }, [isEditing]);
+
+ const handleForceSync = async () => {
+ if (!roomName || !isEditing) return;
+
+ // Clear previous results
+ setSyncStatus("syncing");
+ setSyncResult(null);
+ setSyncMessage("");
+
+ try {
+ const result = await syncMutation.mutateAsync({
+ params: {
+ path: { room_name: roomName },
+ },
+ });
+
+ if (result.status === "success" || result.status === "unchanged") {
+ setSyncStatus("success");
+ setSyncResult({
+ eventsFound: result.events_found || 0,
+ totalEvents: result.total_events || 0,
+ eventsCreated: result.events_created || 0,
+ eventsUpdated: result.events_updated || 0,
+ });
+ } else {
+ setSyncStatus("error");
+ setSyncMessage(result.error || "Sync failed");
+ }
+ } catch (err: any) {
+ setSyncStatus("error");
+ setSyncMessage(err.body?.detail || "Failed to force sync calendar");
+ }
+ };
+
+ if (!isOwner) {
+ return null; // ICS settings only visible to room owner
+ }
+
+ return (
+
+
+ onChange({ ics_enabled: !!e.checked })}
+ >
+
+
+
+
+ Enable ICS calendar sync
+
+
+
+ {icsEnabled && (
+ <>
+
+ Room URL
+
+ To enable Reflector to recognize your calendar events as meetings,
+ add this URL as the location in your calendar events
+
+
+
+
+
+ {justCopied ? : }
+
+
+
+
+
+
+ ICS Calendar URL
+ onChange({ ics_url: e.target.value })}
+ />
+
+ Enter the ICS URL from Google Calendar, Outlook, or other calendar
+ services
+
+
+
+
+ Sync Interval
+ {
+ const value = parseInt(details.value[0]);
+ onChange({ ics_fetch_interval: value });
+ }}
+ >
+
+
+
+
+ {fetchIntervalOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ How often to check for calendar updates
+
+
+
+ {icsUrl && isEditing && roomName && (
+
+
+ {syncStatus === "syncing" ? (
+
+ ) : (
+
+ )}
+ Force Sync
+
+
+ )}
+
+ {syncResult && syncStatus === "success" && (
+
+
+
+ Sync completed
+
+
+ {syncResult.totalEvents} events downloaded,{" "}
+ {syncResult.eventsFound} match this room
+
+ {(syncResult.eventsCreated > 0 ||
+ syncResult.eventsUpdated > 0) && (
+
+ {syncResult.eventsCreated} created,{" "}
+ {syncResult.eventsUpdated} updated
+
+ )}
+
+
+ )}
+
+ {syncMessage && (
+
+
+ {syncMessage}
+
+
+ )}
+
+ {icsLastSync && (
+
+
+
+ Last sync: {new Date(icsLastSync).toLocaleString()}
+
+ {icsLastEtag && (
+
+ ETag: {icsLastEtag.slice(0, 8)}...
+
+ )}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/www/app/(app)/rooms/_components/RoomList.tsx b/www/app/(app)/rooms/_components/RoomList.tsx
index 218c890c..8cd83277 100644
--- a/www/app/(app)/rooms/_components/RoomList.tsx
+++ b/www/app/(app)/rooms/_components/RoomList.tsx
@@ -4,12 +4,13 @@ import type { components } from "../../../reflector-api";
type Room = components["schemas"]["Room"];
import { RoomTable } from "./RoomTable";
import { RoomCards } from "./RoomCards";
+import { NonEmptyString } from "../../../lib/utils";
interface RoomListProps {
title: string;
rooms: Room[];
linkCopied: string;
- onCopyUrl: (roomName: string) => void;
+ onCopyUrl: (roomName: NonEmptyString) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
emptyMessage?: string;
diff --git a/www/app/(app)/rooms/_components/RoomTable.tsx b/www/app/(app)/rooms/_components/RoomTable.tsx
index 113eca7f..ca6c2214 100644
--- a/www/app/(app)/rooms/_components/RoomTable.tsx
+++ b/www/app/(app)/rooms/_components/RoomTable.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useState } from "react";
import {
Box,
Table,
@@ -7,17 +7,58 @@ import {
IconButton,
Text,
Spinner,
+ Badge,
+ VStack,
+ Icon,
} from "@chakra-ui/react";
-import { LuLink } from "react-icons/lu";
+import { LuLink, LuRefreshCw } from "react-icons/lu";
+import { FaCalendarAlt } from "react-icons/fa";
import type { components } from "../../../reflector-api";
+import {
+ useRoomActiveMeetings,
+ useRoomUpcomingMeetings,
+ useRoomIcsSync,
+} from "../../../lib/apiHooks";
type Room = components["schemas"]["Room"];
+type Meeting = components["schemas"]["Meeting"];
+type CalendarEventResponse = components["schemas"]["CalendarEventResponse"];
import { RoomActionsMenu } from "./RoomActionsMenu";
+import { MEETING_DEFAULT_TIME_MINUTES } from "../../../[roomName]/[meetingId]/constants";
+import { NonEmptyString, parseNonEmptyString } from "../../../lib/utils";
+
+// Custom icon component that combines calendar and refresh icons
+const CalendarSyncIcon = () => (
+
+
+
+
+
+
+);
interface RoomTableProps {
rooms: Room[];
linkCopied: string;
- onCopyUrl: (roomName: string) => void;
+ onCopyUrl: (roomName: NonEmptyString) => void;
onEdit: (roomId: string, roomData: any) => void;
onDelete: (roomId: string) => void;
loading?: boolean;
@@ -63,6 +104,71 @@ const getZulipDisplay = (
return "Enabled";
};
+function MeetingStatus({ roomName }: { roomName: string }) {
+ const activeMeetingsQuery = useRoomActiveMeetings(roomName);
+ const upcomingMeetingsQuery = useRoomUpcomingMeetings(roomName);
+
+ const activeMeetings = activeMeetingsQuery.data || [];
+ const upcomingMeetings = upcomingMeetingsQuery.data || [];
+
+ if (activeMeetingsQuery.isLoading || upcomingMeetingsQuery.isLoading) {
+ return ;
+ }
+
+ if (activeMeetings.length > 0) {
+ const meeting = activeMeetings[0];
+ const title = String(
+ meeting.calendar_metadata?.["title"] || "Active Meeting",
+ );
+ return (
+
+
+ {title}
+
+
+ {meeting.num_clients} participants
+
+
+ );
+ }
+
+ if (upcomingMeetings.length > 0) {
+ const event = upcomingMeetings[0];
+ const startTime = new Date(event.start_time);
+ const now = new Date();
+ const diffMinutes = Math.floor(
+ (startTime.getTime() - now.getTime()) / 60000,
+ );
+
+ return (
+
+
+ {diffMinutes < MEETING_DEFAULT_TIME_MINUTES
+ ? `In ${diffMinutes}m`
+ : "Upcoming"}
+
+
+ {event.title || "Scheduled Meeting"}
+
+
+ {startTime.toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ month: "short",
+ day: "numeric",
+ })}
+
+
+ );
+ }
+
+ return (
+
+ No meetings
+
+ );
+}
+
export function RoomTable({
rooms,
linkCopied,
@@ -71,6 +177,30 @@ export function RoomTable({
onDelete,
loading,
}: RoomTableProps) {
+ const [syncingRooms, setSyncingRooms] = useState>(
+ new Set(),
+ );
+ const syncMutation = useRoomIcsSync();
+
+ const handleForceSync = async (roomName: NonEmptyString) => {
+ setSyncingRooms((prev) => new Set(prev).add(roomName));
+ try {
+ await syncMutation.mutateAsync({
+ params: {
+ path: { room_name: roomName },
+ },
+ });
+ } catch (err) {
+ console.error("Failed to sync calendar:", err);
+ } finally {
+ setSyncingRooms((prev) => {
+ const next = new Set(prev);
+ next.delete(roomName);
+ return next;
+ });
+ }
+ };
+
return (
{loading && (
@@ -97,13 +227,16 @@ export function RoomTable({
Room Name
-
- Zulip
-
-
- Room Size
+
+ Current Meeting
+ Zulip
+
+
+ Room Size
+
+
Recording
{room.name}
+
+
+
{getZulipDisplay(
room.zulip_auto_post,
@@ -133,7 +269,26 @@ export function RoomTable({
)}
-
+
+ {room.ics_enabled && (
+
+ handleForceSync(parseNonEmptyString(room.name))
+ }
+ size="sm"
+ variant="ghost"
+ disabled={syncingRooms.has(
+ parseNonEmptyString(room.name),
+ )}
+ >
+ {syncingRooms.has(parseNonEmptyString(room.name)) ? (
+
+ ) : (
+
+ )}
+
+ )}
{linkCopied === room.name ? (
Copied!
@@ -141,7 +296,9 @@ export function RoomTable({
) : (
onCopyUrl(room.name)}
+ onClick={() =>
+ onCopyUrl(parseNonEmptyString(room.name))
+ }
size="sm"
variant="ghost"
>
diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx
index 8b1378df..88e66720 100644
--- a/www/app/(app)/rooms/page.tsx
+++ b/www/app/(app)/rooms/page.tsx
@@ -14,6 +14,7 @@ import {
IconButton,
createListCollection,
useDisclosure,
+ Tabs,
} from "@chakra-ui/react";
import { useEffect, useMemo, useState } from "react";
import { LuEye, LuEyeOff } from "react-icons/lu";
@@ -30,7 +31,13 @@ import {
} from "../../lib/apiHooks";
import { RoomList } from "./_components/RoomList";
import { PaginationPage } from "../browse/_components/Pagination";
-import { assertExists } from "../../lib/utils";
+import {
+ assertExists,
+ NonEmptyString,
+ parseNonEmptyString,
+} from "../../lib/utils";
+import ICSSettings from "./_components/ICSSettings";
+import { roomAbsoluteUrl } from "../../lib/routesClient";
type Room = components["schemas"]["Room"];
@@ -40,6 +47,8 @@ interface SelectOption {
}
const RESERVED_PATHS = ["browse", "rooms", "transcripts"];
+const SUCCESS_EMOJI = "✅";
+const ERROR_EMOJI = "❌";
const roomModeOptions: SelectOption[] = [
{ label: "2-4 people", value: "normal" },
@@ -70,6 +79,9 @@ const roomInitialState = {
isShared: false,
webhookUrl: "",
webhookSecret: "",
+ icsUrl: "",
+ icsEnabled: false,
+ icsFetchInterval: 5,
};
export default function RoomsList() {
@@ -137,6 +149,9 @@ export default function RoomsList() {
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,
}
: null,
[detailedEditedRoom],
@@ -176,14 +191,13 @@ export default function RoomsList() {
items: topicOptions,
});
- const handleCopyUrl = (roomName: string) => {
- const roomUrl = `${window.location.origin}/${roomName}`;
- navigator.clipboard.writeText(roomUrl);
- setLinkCopied(roomName);
-
- setTimeout(() => {
- setLinkCopied("");
- }, 2000);
+ const handleCopyUrl = (roomName: NonEmptyString) => {
+ navigator.clipboard.writeText(roomAbsoluteUrl(roomName)).then(() => {
+ setLinkCopied(roomName);
+ setTimeout(() => {
+ setLinkCopied("");
+ }, 2000);
+ });
};
const handleCloseDialog = () => {
@@ -217,10 +231,10 @@ export default function RoomsList() {
if (response.success) {
setWebhookTestResult(
- `✅ Webhook test successful! Status: ${response.status_code}`,
+ `${SUCCESS_EMOJI} Webhook test successful! Status: ${response.status_code}`,
);
} else {
- let errorMsg = `❌ Webhook test failed`;
+ let errorMsg = `${ERROR_EMOJI} Webhook test failed`;
errorMsg += ` (Status: ${response.status_code})`;
if (response.error) {
errorMsg += `: ${response.error}`;
@@ -275,6 +289,9 @@ export default function RoomsList() {
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,
};
if (isEditing) {
@@ -316,6 +333,22 @@ export default function RoomsList() {
setShowWebhookSecret(false);
setWebhookTestResult(null);
+ setRoomInput({
+ name: roomData.name,
+ zulipAutoPost: roomData.zulip_auto_post,
+ zulipStream: roomData.zulip_stream,
+ zulipTopic: roomData.zulip_topic,
+ isLocked: roomData.is_locked,
+ roomMode: roomData.room_mode,
+ recordingType: roomData.recording_type,
+ recordingTrigger: 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,
+ });
setEditRoomId(roomId);
setIsEditing(true);
setNameError("");
@@ -416,353 +449,407 @@ export default function RoomsList() {
-
- Room name
-
-
- No spaces or special characters allowed
-
- {nameError && {nameError}}
-
+
+
+ General
+ Calendar
+ Share
+ WebHook
+
-
- {
- const syntheticEvent = {
- target: {
- name: "isLocked",
- type: "checkbox",
- checked: e.checked,
- },
- };
- handleRoomChange(syntheticEvent);
- }}
- >
-
-
-
-
- Locked room
-
-
-
- Room size
-
- setRoomInput({ ...room, roomMode: e.value[0] })
- }
- collection={roomModeCollection}
- >
-
-
-
-
-
-
-
-
-
-
-
- {roomModeOptions.map((option) => (
-
- {option.label}
-
-
- ))}
-
-
-
-
-
- Recording type
-
- setRoomInput({
- ...room,
- recordingType: e.value[0],
- recordingTrigger:
- e.value[0] !== "cloud" ? "none" : room.recordingTrigger,
- })
- }
- collection={recordingTypeCollection}
- >
-
-
-
-
-
-
-
-
-
-
-
- {recordingTypeOptions.map((option) => (
-
- {option.label}
-
-
- ))}
-
-
-
-
-
- Cloud recording start trigger
-
- setRoomInput({ ...room, recordingTrigger: e.value[0] })
- }
- collection={recordingTriggerCollection}
- disabled={room.recordingType !== "cloud"}
- >
-
-
-
-
-
-
-
-
-
-
-
- {recordingTriggerOptions.map((option) => (
-
- {option.label}
-
-
- ))}
-
-
-
-
-
- {
- const syntheticEvent = {
- target: {
- name: "zulipAutoPost",
- type: "checkbox",
- checked: e.checked,
- },
- };
- handleRoomChange(syntheticEvent);
- }}
- >
-
-
-
-
-
- Automatically post transcription to Zulip
-
-
-
-
- Zulip stream
-
- setRoomInput({
- ...room,
- zulipStream: e.value[0],
- zulipTopic: "",
- })
- }
- collection={streamCollection}
- disabled={!room.zulipAutoPost}
- >
-
-
-
-
-
-
-
-
-
-
-
- {streamOptions.map((option) => (
-
- {option.label}
-
-
- ))}
-
-
-
-
-
- Zulip topic
-
- setRoomInput({ ...room, zulipTopic: e.value[0] })
- }
- collection={topicCollection}
- disabled={!room.zulipAutoPost}
- >
-
-
-
-
-
-
-
-
-
-
-
- {topicOptions.map((option) => (
-
- {option.label}
-
-
- ))}
-
-
-
-
-
- {/* Webhook Configuration Section */}
-
- Webhook URL
-
-
- Optional: URL to receive notifications when transcripts are
- ready
-
-
-
- {room.webhookUrl && (
- <>
-
- Webhook Secret
-
-
- {isEditing && room.webhookSecret && (
-
- setShowWebhookSecret(!showWebhookSecret)
- }
- >
- {showWebhookSecret ? : }
-
- )}
-
+
+
+ Room name
+
- Used for HMAC signature verification (auto-generated if
- left empty)
+ No spaces or special characters allowed
+
+ {nameError && (
+ {nameError}
+ )}
+
+
+
+ {
+ const syntheticEvent = {
+ target: {
+ name: "isLocked",
+ type: "checkbox",
+ checked: e.checked,
+ },
+ };
+ handleRoomChange(syntheticEvent);
+ }}
+ >
+
+
+
+
+ Locked room
+
+
+
+
+ Room size
+
+ setRoomInput({ ...room, roomMode: e.value[0] })
+ }
+ collection={roomModeCollection}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {roomModeOptions.map((option) => (
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+
+ Recording type
+
+ setRoomInput({
+ ...room,
+ recordingType: e.value[0],
+ recordingTrigger:
+ e.value[0] !== "cloud"
+ ? "none"
+ : room.recordingTrigger,
+ })
+ }
+ collection={recordingTypeCollection}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {recordingTypeOptions.map((option) => (
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+
+ Cloud recording start trigger
+
+ setRoomInput({ ...room, recordingTrigger: e.value[0] })
+ }
+ collection={recordingTriggerCollection}
+ disabled={room.recordingType !== "cloud"}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {recordingTriggerOptions.map((option) => (
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+
+ {
+ const syntheticEvent = {
+ target: {
+ name: "isShared",
+ type: "checkbox",
+ checked: e.checked,
+ },
+ };
+ handleRoomChange(syntheticEvent);
+ }}
+ >
+
+
+
+
+ Shared room
+
+
+
+
+
+ {
+ setRoomInput({
+ ...room,
+ icsUrl:
+ settings.ics_url !== undefined
+ ? settings.ics_url
+ : room.icsUrl,
+ icsEnabled:
+ settings.ics_enabled !== undefined
+ ? settings.ics_enabled
+ : room.icsEnabled,
+ icsFetchInterval:
+ settings.ics_fetch_interval !== undefined
+ ? settings.ics_fetch_interval
+ : room.icsFetchInterval,
+ });
+ }}
+ isOwner={true}
+ isEditing={isEditing}
+ />
+
+
+
+
+ {
+ const syntheticEvent = {
+ target: {
+ name: "zulipAutoPost",
+ type: "checkbox",
+ checked: e.checked,
+ },
+ };
+ handleRoomChange(syntheticEvent);
+ }}
+ >
+
+
+
+
+
+ Automatically post transcription to Zulip
+
+
+
+
+
+ Zulip stream
+
+ setRoomInput({
+ ...room,
+ zulipStream: e.value[0],
+ zulipTopic: "",
+ })
+ }
+ collection={streamCollection}
+ disabled={!room.zulipAutoPost}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {streamOptions.map((option) => (
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+
+ Zulip topic
+
+ setRoomInput({ ...room, zulipTopic: e.value[0] })
+ }
+ collection={topicCollection}
+ disabled={!room.zulipAutoPost}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ {topicOptions.map((option) => (
+
+ {option.label}
+
+
+ ))}
+
+
+
+
+
+
+
+
+ Webhook URL
+
+
+ Optional: URL to receive notifications when transcripts
+ are ready
- {isEditing && (
+ {room.webhookUrl && (
<>
-
-
- {testingWebhook ? (
- <>
-
- Testing...
- >
- ) : (
- "Test Webhook"
+
+ Webhook Secret
+
+
+ {isEditing && room.webhookSecret && (
+
+ setShowWebhookSecret(!showWebhookSecret)
+ }
+ >
+ {showWebhookSecret ? : }
+
)}
-
- {webhookTestResult && (
-
+
+ Used for HMAC signature verification (auto-generated
+ if left empty)
+
+
+
+ {isEditing && (
+ <>
+
- {webhookTestResult}
-
- )}
-
+
+ {testingWebhook ? (
+ <>
+
+ Testing...
+ >
+ ) : (
+ "Test Webhook"
+ )}
+
+ {webhookTestResult && (
+
+ {webhookTestResult}
+
+ )}
+
+ >
+ )}
>
)}
- >
- )}
-
-
- {
- const syntheticEvent = {
- target: {
- name: "isShared",
- type: "checkbox",
- checked: e.checked,
- },
- };
- handleRoomChange(syntheticEvent);
- }}
- >
-
-
-
-
- Shared room
-
-
+
+
diff --git a/www/app/[roomName]/MeetingSelection.tsx b/www/app/[roomName]/MeetingSelection.tsx
new file mode 100644
index 00000000..2780acbd
--- /dev/null
+++ b/www/app/[roomName]/MeetingSelection.tsx
@@ -0,0 +1,569 @@
+"use client";
+
+import { partition } from "remeda";
+import {
+ Box,
+ VStack,
+ HStack,
+ Text,
+ Button,
+ Spinner,
+ Badge,
+ Icon,
+ Flex,
+} from "@chakra-ui/react";
+import React from "react";
+import { FaUsers, FaClock, FaCalendarAlt, FaPlus } from "react-icons/fa";
+import { LuX } from "react-icons/lu";
+import type { components } from "../reflector-api";
+import {
+ useRoomActiveMeetings,
+ useRoomJoinMeeting,
+ useMeetingDeactivate,
+ useRoomGetByName,
+} from "../lib/apiHooks";
+import { useRouter } from "next/navigation";
+import { formatDateTime, formatStartedAgo } from "../lib/timeUtils";
+import MeetingMinimalHeader from "../components/MeetingMinimalHeader";
+import { NonEmptyString } from "../lib/utils";
+
+type Meeting = components["schemas"]["Meeting"];
+
+interface MeetingSelectionProps {
+ roomName: NonEmptyString;
+ isOwner: boolean;
+ isSharedRoom: boolean;
+ authLoading: boolean;
+ onMeetingSelect: (meeting: Meeting) => void;
+ onCreateUnscheduled: () => void;
+ isCreatingMeeting?: boolean;
+}
+
+export default function MeetingSelection({
+ roomName,
+ isOwner,
+ isSharedRoom,
+ onMeetingSelect,
+ onCreateUnscheduled,
+ isCreatingMeeting = false,
+}: MeetingSelectionProps) {
+ const router = useRouter();
+ const roomQuery = useRoomGetByName(roomName);
+ const activeMeetingsQuery = useRoomActiveMeetings(roomName);
+ const joinMeetingMutation = useRoomJoinMeeting();
+ const deactivateMeetingMutation = useMeetingDeactivate();
+
+ const room = roomQuery.data;
+ const allMeetings = activeMeetingsQuery.data || [];
+
+ const now = new Date();
+ const [currentMeetings, nonCurrentMeetings] = partition(
+ allMeetings,
+ (meeting) => {
+ const startTime = new Date(meeting.start_date);
+ const endTime = new Date(meeting.end_date);
+ // Meeting is ongoing if current time is between start and end
+ return now >= startTime && now <= endTime;
+ },
+ );
+
+ const upcomingMeetings = nonCurrentMeetings.filter((meeting) => {
+ const startTime = new Date(meeting.start_date);
+ // Meeting is upcoming if it hasn't started yet
+ return now < startTime;
+ });
+
+ const loading = roomQuery.isLoading || activeMeetingsQuery.isLoading;
+ const error = roomQuery.error || activeMeetingsQuery.error;
+
+ const handleJoinUpcoming = async (meeting: Meeting) => {
+ // Join the upcoming meeting and navigate to local meeting page
+ try {
+ const joinedMeeting = await joinMeetingMutation.mutateAsync({
+ params: {
+ path: {
+ room_name: roomName,
+ meeting_id: meeting.id,
+ },
+ },
+ });
+ onMeetingSelect(joinedMeeting);
+ } catch (err) {
+ console.error("Failed to join upcoming meeting:", err);
+ }
+ };
+
+ const handleJoinDirect = (meeting: Meeting) => {
+ // Navigate to local meeting page instead of external URL
+ onMeetingSelect(meeting);
+ };
+
+ const handleEndMeeting = async (meetingId: string) => {
+ try {
+ await deactivateMeetingMutation.mutateAsync({
+ params: {
+ path: {
+ meeting_id: meetingId,
+ },
+ },
+ });
+ } catch (err) {
+ console.error("Failed to end meeting:", err);
+ }
+ };
+
+ if (loading) {
+ return (
+
+
+ Loading meetings...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+ Error
+
+ {"Failed to load meetings"}
+
+ );
+ }
+
+ const handleLeaveMeeting = () => {
+ router.push("/");
+ };
+
+ return (
+
+ {/* Loading overlay */}
+ {isCreatingMeeting && (
+
+
+
+
+ Creating meeting...
+
+
+
+ )}
+
+
+
+
+ {/* Current Ongoing Meetings - BIG DISPLAY */}
+ {currentMeetings.length > 0 ? (
+
+ {currentMeetings.map((meeting) => (
+
+
+
+
+
+
+ {(meeting.calendar_metadata as any)?.title ||
+ "Live Meeting"}
+
+
+
+ {isOwner &&
+ (meeting.calendar_metadata as any)?.description && (
+
+ {(meeting.calendar_metadata as any).description}
+
+ )}
+
+
+
+
+
+ {meeting.num_clients || 0} participant
+ {meeting.num_clients !== 1 ? "s" : ""}
+
+
+
+
+
+ {formatStartedAgo(new Date(meeting.start_date))}
+
+
+
+
+ {isOwner &&
+ (meeting.calendar_metadata as any)?.attendees && (
+
+ {(meeting.calendar_metadata as any).attendees
+ .slice(0, 4)
+ .map((attendee: any, idx: number) => (
+
+ {attendee.name || attendee.email}
+
+ ))}
+ {(meeting.calendar_metadata as any).attendees.length >
+ 4 && (
+
+ +
+ {(meeting.calendar_metadata as any).attendees
+ .length - 4}{" "}
+ more
+
+ )}
+
+ )}
+
+
+
+ handleJoinDirect(meeting)}
+ >
+
+ Join Now
+
+ {isOwner && (
+ handleEndMeeting(meeting.id)}
+ loading={deactivateMeetingMutation.isPending}
+ >
+
+ End Meeting
+
+ )}
+
+
+
+ ))}
+
+ ) : upcomingMeetings.length > 0 ? (
+ /* Upcoming Meetings - BIG DISPLAY when no ongoing meetings */
+
+
+ Upcoming Meeting{upcomingMeetings.length > 1 ? "s" : ""}
+
+ {upcomingMeetings.map((meeting) => {
+ const now = new Date();
+ const startTime = new Date(meeting.start_date);
+ const minutesUntilStart = Math.floor(
+ (startTime.getTime() - now.getTime()) / (1000 * 60),
+ );
+
+ return (
+
+
+
+
+
+
+ {(meeting.calendar_metadata as any)?.title ||
+ "Upcoming Meeting"}
+
+
+
+ {isOwner &&
+ (meeting.calendar_metadata as any)?.description && (
+
+ {(meeting.calendar_metadata as any).description}
+
+ )}
+
+
+
+ Starts in {minutesUntilStart} minute
+ {minutesUntilStart !== 1 ? "s" : ""}
+
+
+ {formatDateTime(new Date(meeting.start_date))}
+
+
+
+ {isOwner &&
+ (meeting.calendar_metadata as any)?.attendees && (
+
+ {(meeting.calendar_metadata as any).attendees
+ .slice(0, 4)
+ .map((attendee: any, idx: number) => (
+
+ {attendee.name || attendee.email}
+
+ ))}
+ {(meeting.calendar_metadata as any).attendees
+ .length > 4 && (
+
+ +
+ {(meeting.calendar_metadata as any).attendees
+ .length - 4}{" "}
+ more
+
+ )}
+
+ )}
+
+
+
+ handleJoinUpcoming(meeting)}
+ >
+
+ Join Early
+
+ {isOwner && (
+ handleEndMeeting(meeting.id)}
+ loading={deactivateMeetingMutation.isPending}
+ >
+
+ Cancel Meeting
+
+ )}
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ {/* Upcoming Meetings - SMALLER ASIDE DISPLAY when there are ongoing meetings */}
+ {currentMeetings.length > 0 && upcomingMeetings.length > 0 && (
+
+
+ Starting Soon
+
+
+ {upcomingMeetings.map((meeting) => {
+ const now = new Date();
+ const startTime = new Date(meeting.start_date);
+ const minutesUntilStart = Math.floor(
+ (startTime.getTime() - now.getTime()) / (1000 * 60),
+ );
+
+ return (
+
+
+
+
+
+ {(meeting.calendar_metadata as any)?.title ||
+ "Upcoming Meeting"}
+
+
+
+
+ in {minutesUntilStart} minute
+ {minutesUntilStart !== 1 ? "s" : ""}
+
+
+
+ Starts: {formatDateTime(new Date(meeting.start_date))}
+
+
+ handleJoinUpcoming(meeting)}
+ >
+ Join Early
+
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* No meetings message - show when no ongoing or upcoming meetings */}
+ {currentMeetings.length === 0 && upcomingMeetings.length === 0 && (
+
+
+
+
+
+ No meetings right now
+
+
+ There are no ongoing or upcoming meetings in this room at the
+ moment.
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/www/app/[roomName]/[meetingId]/constants.ts b/www/app/[roomName]/[meetingId]/constants.ts
new file mode 100644
index 00000000..6978da36
--- /dev/null
+++ b/www/app/[roomName]/[meetingId]/constants.ts
@@ -0,0 +1 @@
+export const MEETING_DEFAULT_TIME_MINUTES = 60;
diff --git a/www/app/[roomName]/[meetingId]/page.tsx b/www/app/[roomName]/[meetingId]/page.tsx
new file mode 100644
index 00000000..8ce405ba
--- /dev/null
+++ b/www/app/[roomName]/[meetingId]/page.tsx
@@ -0,0 +1,3 @@
+import Room from "../room";
+
+export default Room;
diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx
index 867aeb3e..1aaca4c7 100644
--- a/www/app/[roomName]/page.tsx
+++ b/www/app/[roomName]/page.tsx
@@ -1,336 +1,3 @@
-"use client";
+import Room from "./room";
-import {
- useCallback,
- useEffect,
- useRef,
- useState,
- useContext,
- RefObject,
- use,
-} from "react";
-import {
- Box,
- Button,
- Text,
- VStack,
- HStack,
- Spinner,
- Icon,
-} from "@chakra-ui/react";
-import { toaster } from "../components/ui/toaster";
-import useRoomMeeting from "./useRoomMeeting";
-import { useRouter } from "next/navigation";
-import { notFound } from "next/navigation";
-import { useRecordingConsent } from "../recordingConsentContext";
-import { useMeetingAudioConsent } from "../lib/apiHooks";
-import type { components } from "../reflector-api";
-
-type Meeting = components["schemas"]["Meeting"];
-import { FaBars } from "react-icons/fa6";
-import { useAuth } from "../lib/AuthProvider";
-
-export type RoomDetails = {
- params: Promise<{
- roomName: string;
- }>;
-};
-
-// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
-const useConsentWherebyFocusManagement = (
- acceptButtonRef: RefObject,
- wherebyRef: RefObject,
-) => {
- const currentFocusRef = useRef(null);
- useEffect(() => {
- if (acceptButtonRef.current) {
- acceptButtonRef.current.focus();
- } else {
- console.error(
- "accept button ref not available yet for focus management - seems to be illegal state",
- );
- }
-
- const handleWherebyReady = () => {
- console.log("whereby ready - refocusing consent button");
- currentFocusRef.current = document.activeElement as HTMLElement;
- if (acceptButtonRef.current) {
- acceptButtonRef.current.focus();
- }
- };
-
- if (wherebyRef.current) {
- wherebyRef.current.addEventListener("ready", handleWherebyReady);
- } else {
- console.warn(
- "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
- );
- }
-
- return () => {
- wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
- currentFocusRef.current?.focus();
- };
- }, []);
-};
-
-const useConsentDialog = (
- meetingId: string,
- wherebyRef: RefObject /*accessibility*/,
-) => {
- const { state: consentState, touch, hasConsent } = useRecordingConsent();
- // toast would open duplicates, even with using "id=" prop
- const [modalOpen, setModalOpen] = useState(false);
- const audioConsentMutation = useMeetingAudioConsent();
-
- const handleConsent = useCallback(
- async (meetingId: string, given: boolean) => {
- try {
- await audioConsentMutation.mutateAsync({
- params: {
- path: {
- meeting_id: meetingId,
- },
- },
- body: {
- consent_given: given,
- },
- });
-
- touch(meetingId);
- } catch (error) {
- console.error("Error submitting consent:", error);
- }
- },
- [audioConsentMutation, touch],
- );
-
- const showConsentModal = useCallback(() => {
- if (modalOpen) return;
-
- setModalOpen(true);
-
- const toastId = toaster.create({
- placement: "top",
- duration: null,
- render: ({ dismiss }) => {
- const AcceptButton = () => {
- const buttonRef = useRef(null);
- useConsentWherebyFocusManagement(buttonRef, wherebyRef);
- return (
- {
- handleConsent(meetingId, true).then(() => {
- /*signifies it's ok to now wait here.*/
- });
- dismiss();
- }}
- >
- Yes, store the audio
-
- );
- };
-
- return (
-
-
-
- Can we have your permission to store this meeting's audio
- recording on our servers?
-
-
- {
- handleConsent(meetingId, false).then(() => {
- /*signifies it's ok to now wait here.*/
- });
- dismiss();
- }}
- >
- No, delete after transcription
-
-
-
-
-
- );
- },
- });
-
- // Set modal state when toast is dismissed
- toastId.then((id) => {
- const checkToastStatus = setInterval(() => {
- if (!toaster.isActive(id)) {
- setModalOpen(false);
- clearInterval(checkToastStatus);
- }
- }, 100);
- });
-
- // Handle escape key to close the toast
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.key === "Escape") {
- toastId.then((id) => toaster.dismiss(id));
- }
- };
-
- document.addEventListener("keydown", handleKeyDown);
-
- const cleanup = () => {
- toastId.then((id) => toaster.dismiss(id));
- document.removeEventListener("keydown", handleKeyDown);
- };
-
- return cleanup;
- }, [meetingId, handleConsent, wherebyRef, modalOpen]);
-
- return {
- showConsentModal,
- consentState,
- hasConsent,
- consentLoading: audioConsentMutation.isPending,
- };
-};
-
-function ConsentDialogButton({
- meetingId,
- wherebyRef,
-}: {
- meetingId: string;
- wherebyRef: React.RefObject;
-}) {
- const { showConsentModal, consentState, hasConsent, consentLoading } =
- useConsentDialog(meetingId, wherebyRef);
-
- if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
- return null;
- }
-
- return (
-
- Meeting is being recorded
-
-
- );
-}
-
-const recordingTypeRequiresConsent = (
- recordingType: NonNullable,
-) => {
- return recordingType === "cloud";
-};
-
-// next throws even with "use client"
-const useWhereby = () => {
- const [wherebyLoaded, setWherebyLoaded] = useState(false);
- useEffect(() => {
- if (typeof window !== "undefined") {
- import("@whereby.com/browser-sdk/embed")
- .then(() => {
- setWherebyLoaded(true);
- })
- .catch(console.error.bind(console));
- }
- }, []);
- return wherebyLoaded;
-};
-
-export default function Room(details: RoomDetails) {
- const params = use(details.params);
- const wherebyLoaded = useWhereby();
- const wherebyRef = useRef(null);
- const roomName = params.roomName;
- const meeting = useRoomMeeting(roomName);
- const router = useRouter();
- const status = useAuth().status;
- const isAuthenticated = status === "authenticated";
- const isLoading = status === "loading" || meeting.loading;
-
- const roomUrl = meeting?.response?.host_room_url
- ? meeting?.response?.host_room_url
- : meeting?.response?.room_url;
-
- const meetingId = meeting?.response?.id;
-
- const recordingType = meeting?.response?.recording_type;
-
- const handleLeave = useCallback(() => {
- router.push("/browse");
- }, [router]);
-
- useEffect(() => {
- if (
- !isLoading &&
- meeting?.error &&
- "status" in meeting.error &&
- meeting.error.status === 404
- ) {
- notFound();
- }
- }, [isLoading, meeting?.error]);
-
- useEffect(() => {
- if (isLoading || !isAuthenticated || !roomUrl || !wherebyLoaded) return;
-
- wherebyRef.current?.addEventListener("leave", handleLeave);
-
- return () => {
- wherebyRef.current?.removeEventListener("leave", handleLeave);
- };
- }, [handleLeave, roomUrl, isLoading, isAuthenticated, wherebyLoaded]);
-
- if (isLoading) {
- return (
-
-
-
- );
- }
-
- return (
- <>
- {roomUrl && meetingId && wherebyLoaded && (
- <>
-
- {recordingType && recordingTypeRequiresConsent(recordingType) && (
-
- )}
- >
- )}
- >
- );
-}
+export default Room;
diff --git a/www/app/[roomName]/room.tsx b/www/app/[roomName]/room.tsx
new file mode 100644
index 00000000..780851e2
--- /dev/null
+++ b/www/app/[roomName]/room.tsx
@@ -0,0 +1,437 @@
+"use client";
+
+import { roomMeetingUrl, roomUrl as getRoomUrl } from "../lib/routes";
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ useContext,
+ RefObject,
+ use,
+} from "react";
+import {
+ Box,
+ Button,
+ Text,
+ VStack,
+ HStack,
+ Spinner,
+ Icon,
+} from "@chakra-ui/react";
+import { toaster } from "../components/ui/toaster";
+import { useRouter } from "next/navigation";
+import { useRecordingConsent } from "../recordingConsentContext";
+import {
+ useMeetingAudioConsent,
+ useRoomGetByName,
+ useRoomActiveMeetings,
+ useRoomUpcomingMeetings,
+ useRoomsCreateMeeting,
+ useRoomGetMeeting,
+} from "../lib/apiHooks";
+import type { components } from "../reflector-api";
+import MeetingSelection from "./MeetingSelection";
+import useRoomDefaultMeeting from "./useRoomDefaultMeeting";
+
+type Meeting = components["schemas"]["Meeting"];
+import { FaBars } from "react-icons/fa6";
+import { useAuth } from "../lib/AuthProvider";
+import { getWherebyUrl, useWhereby } from "../lib/wherebyClient";
+import { useError } from "../(errors)/errorContext";
+import {
+ assertExistsAndNonEmptyString,
+ NonEmptyString,
+ parseNonEmptyString,
+} from "../lib/utils";
+import { printApiError } from "../api/_error";
+
+export type RoomDetails = {
+ params: Promise<{
+ roomName: string;
+ meetingId?: string;
+ }>;
+};
+
+// stages: we focus on the consent, then whereby steals focus, then we focus on the consent again, then return focus to whoever stole it initially
+const useConsentWherebyFocusManagement = (
+ acceptButtonRef: RefObject,
+ wherebyRef: RefObject,
+) => {
+ const currentFocusRef = useRef(null);
+ useEffect(() => {
+ if (acceptButtonRef.current) {
+ acceptButtonRef.current.focus();
+ } else {
+ console.error(
+ "accept button ref not available yet for focus management - seems to be illegal state",
+ );
+ }
+
+ const handleWherebyReady = () => {
+ console.log("whereby ready - refocusing consent button");
+ currentFocusRef.current = document.activeElement as HTMLElement;
+ if (acceptButtonRef.current) {
+ acceptButtonRef.current.focus();
+ }
+ };
+
+ if (wherebyRef.current) {
+ wherebyRef.current.addEventListener("ready", handleWherebyReady);
+ } else {
+ console.warn(
+ "whereby ref not available yet for focus management - seems to be illegal state. not waiting, focus management off.",
+ );
+ }
+
+ return () => {
+ wherebyRef.current?.removeEventListener("ready", handleWherebyReady);
+ currentFocusRef.current?.focus();
+ };
+ }, []);
+};
+
+const useConsentDialog = (
+ meetingId: string,
+ wherebyRef: RefObject /*accessibility*/,
+) => {
+ const { state: consentState, touch, hasConsent } = useRecordingConsent();
+ // toast would open duplicates, even with using "id=" prop
+ const [modalOpen, setModalOpen] = useState(false);
+ const audioConsentMutation = useMeetingAudioConsent();
+
+ const handleConsent = useCallback(
+ async (meetingId: string, given: boolean) => {
+ try {
+ await audioConsentMutation.mutateAsync({
+ params: {
+ path: {
+ meeting_id: meetingId,
+ },
+ },
+ body: {
+ consent_given: given,
+ },
+ });
+
+ touch(meetingId);
+ } catch (error) {
+ console.error("Error submitting consent:", error);
+ }
+ },
+ [audioConsentMutation, touch],
+ );
+
+ const showConsentModal = useCallback(() => {
+ if (modalOpen) return;
+
+ setModalOpen(true);
+
+ const toastId = toaster.create({
+ placement: "top",
+ duration: null,
+ render: ({ dismiss }) => {
+ const AcceptButton = () => {
+ const buttonRef = useRef(null);
+ useConsentWherebyFocusManagement(buttonRef, wherebyRef);
+ return (
+ {
+ handleConsent(meetingId, true).then(() => {
+ /*signifies it's ok to now wait here.*/
+ });
+ dismiss();
+ }}
+ >
+ Yes, store the audio
+
+ );
+ };
+
+ return (
+
+
+
+ Can we have your permission to store this meeting's audio
+ recording on our servers?
+
+
+ {
+ handleConsent(meetingId, false).then(() => {
+ /*signifies it's ok to now wait here.*/
+ });
+ dismiss();
+ }}
+ >
+ No, delete after transcription
+
+
+
+
+
+ );
+ },
+ });
+
+ // Set modal state when toast is dismissed
+ toastId.then((id) => {
+ const checkToastStatus = setInterval(() => {
+ if (!toaster.isActive(id)) {
+ setModalOpen(false);
+ clearInterval(checkToastStatus);
+ }
+ }, 100);
+ });
+
+ // Handle escape key to close the toast
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ toastId.then((id) => toaster.dismiss(id));
+ }
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+
+ const cleanup = () => {
+ toastId.then((id) => toaster.dismiss(id));
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+
+ return cleanup;
+ }, [meetingId, handleConsent, wherebyRef, modalOpen]);
+
+ return {
+ showConsentModal,
+ consentState,
+ hasConsent,
+ consentLoading: audioConsentMutation.isPending,
+ };
+};
+
+function ConsentDialogButton({
+ meetingId,
+ wherebyRef,
+}: {
+ meetingId: NonEmptyString;
+ wherebyRef: React.RefObject;
+}) {
+ const { showConsentModal, consentState, hasConsent, consentLoading } =
+ useConsentDialog(meetingId, wherebyRef);
+
+ if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
+ return null;
+ }
+
+ return (
+
+ Meeting is being recorded
+
+
+ );
+}
+
+const recordingTypeRequiresConsent = (
+ recordingType: NonNullable,
+) => {
+ return recordingType === "cloud";
+};
+
+export default function Room(details: RoomDetails) {
+ const params = use(details.params);
+ const wherebyLoaded = useWhereby();
+ const wherebyRef = useRef(null);
+ const roomName = parseNonEmptyString(params.roomName);
+ const router = useRouter();
+ const auth = useAuth();
+ const status = auth.status;
+ const isAuthenticated = status === "authenticated";
+ const { setError } = useError();
+
+ const roomQuery = useRoomGetByName(roomName);
+ const createMeetingMutation = useRoomsCreateMeeting();
+
+ const room = roomQuery.data;
+
+ const pageMeetingId = params.meetingId;
+
+ // this one is called on room page
+ const defaultMeeting = useRoomDefaultMeeting(
+ room && !room.ics_enabled && !pageMeetingId ? roomName : null,
+ );
+
+ const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
+ const wherebyRoomUrl = explicitMeeting.data
+ ? getWherebyUrl(explicitMeeting.data)
+ : defaultMeeting.response
+ ? getWherebyUrl(defaultMeeting.response)
+ : null;
+ const recordingType = (explicitMeeting.data || defaultMeeting.response)
+ ?.recording_type;
+ const meetingId = (explicitMeeting.data || defaultMeeting.response)?.id;
+
+ const isLoading =
+ status === "loading" ||
+ roomQuery.isLoading ||
+ defaultMeeting?.loading ||
+ explicitMeeting.isLoading;
+
+ const errors = [
+ explicitMeeting.error,
+ defaultMeeting.error,
+ roomQuery.error,
+ createMeetingMutation.error,
+ ].filter(Boolean);
+
+ const isOwner =
+ isAuthenticated && room ? auth.user?.id === room.user_id : false;
+
+ const handleMeetingSelect = (selectedMeeting: Meeting) => {
+ router.push(
+ roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)),
+ );
+ };
+
+ const handleCreateUnscheduled = async () => {
+ try {
+ // Create a new unscheduled meeting
+ const newMeeting = await createMeetingMutation.mutateAsync({
+ params: {
+ path: { room_name: roomName },
+ },
+ body: {
+ allow_duplicated: room ? room.ics_enabled : false,
+ },
+ });
+ handleMeetingSelect(newMeeting);
+ } catch (err) {
+ console.error("Failed to create meeting:", err);
+ }
+ };
+
+ const handleLeave = useCallback(() => {
+ router.push("/browse");
+ }, [router]);
+
+ useEffect(() => {
+ if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded)
+ return;
+
+ wherebyRef.current?.addEventListener("leave", handleLeave);
+
+ return () => {
+ wherebyRef.current?.removeEventListener("leave", handleLeave);
+ };
+ }, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]);
+
+ useEffect(() => {
+ if (!isLoading && !wherebyRoomUrl) {
+ setError(new Error("Whereby room URL not found"));
+ }
+ }, [isLoading, wherebyRoomUrl]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!room) {
+ return (
+
+ Room not found
+
+ );
+ }
+
+ if (room.ics_enabled && !params.meetingId) {
+ return (
+
+ );
+ }
+
+ if (errors.length > 0) {
+ return (
+
+ {errors.map((error, i) => (
+
+ {printApiError(error)}
+
+ ))}
+
+ );
+ }
+
+ return (
+ <>
+ {wherebyRoomUrl && wherebyLoaded && (
+ <>
+
+ {recordingType &&
+ recordingTypeRequiresConsent(recordingType) &&
+ meetingId && (
+
+ )}
+ >
+ )}
+ >
+ );
+}
diff --git a/www/app/[roomName]/useRoomMeeting.tsx b/www/app/[roomName]/useRoomDefaultMeeting.tsx
similarity index 75%
rename from www/app/[roomName]/useRoomMeeting.tsx
rename to www/app/[roomName]/useRoomDefaultMeeting.tsx
index 93491a05..724e692f 100644
--- a/www/app/[roomName]/useRoomMeeting.tsx
+++ b/www/app/[roomName]/useRoomDefaultMeeting.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react";
+import { useEffect, useState, useRef } from "react";
import { useError } from "../(errors)/errorContext";
import type { components } from "../reflector-api";
import { shouldShowError } from "../lib/errorUtils";
@@ -6,30 +6,31 @@ import { shouldShowError } from "../lib/errorUtils";
type Meeting = components["schemas"]["Meeting"];
import { useRoomsCreateMeeting } from "../lib/apiHooks";
import { notFound } from "next/navigation";
+import { ApiError } from "../api/_error";
type ErrorMeeting = {
- error: Error;
+ error: ApiError;
loading: false;
response: null;
reload: () => void;
};
type LoadingMeeting = {
+ error: null;
response: null;
loading: true;
- error: false;
reload: () => void;
};
type SuccessMeeting = {
+ error: null;
response: Meeting;
loading: false;
- error: null;
reload: () => void;
};
-const useRoomMeeting = (
- roomName: string | null | undefined,
+const useRoomDefaultMeeting = (
+ roomName: string | null,
): ErrorMeeting | LoadingMeeting | SuccessMeeting => {
const [response, setResponse] = useState(null);
const [reload, setReload] = useState(0);
@@ -37,10 +38,15 @@ const useRoomMeeting = (
const createMeetingMutation = useRoomsCreateMeeting();
const reloadHandler = () => setReload((prev) => prev + 1);
+ // this is to undupe dev mode room creation
+ const creatingRef = useRef(false);
+
useEffect(() => {
if (!roomName) return;
+ if (creatingRef.current) return;
const createMeeting = async () => {
+ creatingRef.current = true;
try {
const result = await createMeetingMutation.mutateAsync({
params: {
@@ -48,6 +54,9 @@ const useRoomMeeting = (
room_name: roomName,
},
},
+ body: {
+ allow_duplicated: false,
+ },
});
setResponse(result);
} catch (error: any) {
@@ -60,14 +69,16 @@ const useRoomMeeting = (
} else {
setError(error);
}
+ } finally {
+ creatingRef.current = false;
}
};
- createMeeting();
+ createMeeting().then(() => {});
}, [roomName, reload]);
const loading = createMeetingMutation.isPending && !response;
- const error = createMeetingMutation.error as Error | null;
+ const error = createMeetingMutation.error;
return { response, loading, error, reload: reloadHandler } as
| ErrorMeeting
@@ -75,4 +86,4 @@ const useRoomMeeting = (
| SuccessMeeting;
};
-export default useRoomMeeting;
+export default useRoomDefaultMeeting;
diff --git a/www/app/api/_error.ts b/www/app/api/_error.ts
new file mode 100644
index 00000000..9603b8e8
--- /dev/null
+++ b/www/app/api/_error.ts
@@ -0,0 +1,26 @@
+import { components } from "../reflector-api";
+import { isArray } from "remeda";
+
+export type ApiError = {
+ detail?: components["schemas"]["ValidationError"][];
+} | null;
+
+// errors as declared on api types is not != as they in reality e.g. detail may be a string
+export const printApiError = (error: ApiError) => {
+ if (!error || !error.detail) {
+ return null;
+ }
+ const detail = error.detail as unknown;
+ if (isArray(error.detail)) {
+ return error.detail.map((e) => e.msg).join(", ");
+ }
+ if (typeof detail === "string") {
+ if (detail.length > 0) {
+ return detail;
+ }
+ console.error("Error detail is empty");
+ return null;
+ }
+ console.error("Error detail is not a string or array");
+ return null;
+};
diff --git a/www/app/api/schemas.gen.ts b/www/app/api/schemas.gen.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/www/app/api/services.gen.ts b/www/app/api/services.gen.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/www/app/api/types.gen.ts b/www/app/api/types.gen.ts
deleted file mode 100644
index e69de29b..00000000
diff --git a/www/app/components/MeetingMinimalHeader.tsx b/www/app/components/MeetingMinimalHeader.tsx
new file mode 100644
index 00000000..fe08c9d6
--- /dev/null
+++ b/www/app/components/MeetingMinimalHeader.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { Flex, Link, Button, Text, HStack } from "@chakra-ui/react";
+import NextLink from "next/link";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { roomUrl } from "../lib/routes";
+import { NonEmptyString } from "../lib/utils";
+
+interface MeetingMinimalHeaderProps {
+ roomName: NonEmptyString;
+ displayName?: string;
+ showLeaveButton?: boolean;
+ onLeave?: () => void;
+ showCreateButton?: boolean;
+ onCreateMeeting?: () => void;
+ isCreatingMeeting?: boolean;
+}
+
+export default function MeetingMinimalHeader({
+ roomName,
+ displayName,
+ showLeaveButton = true,
+ onLeave,
+ showCreateButton = false,
+ onCreateMeeting,
+ isCreatingMeeting = false,
+}: MeetingMinimalHeaderProps) {
+ const router = useRouter();
+
+ const handleLeaveMeeting = () => {
+ if (onLeave) {
+ onLeave();
+ } else {
+ router.push(roomUrl(roomName));
+ }
+ };
+
+ const roomTitle = displayName
+ ? displayName.endsWith("'s") || displayName.endsWith("s")
+ ? `${displayName} Room`
+ : `${displayName}'s Room`
+ : `${roomName} Room`;
+
+ return (
+
+ {/* Logo and Room Context */}
+
+
+
+
+
+ {roomTitle}
+
+
+
+ {/* Action Buttons */}
+
+ {showCreateButton && onCreateMeeting && (
+
+ Create Meeting
+
+ )}
+ {showLeaveButton && (
+
+ Leave Room
+
+ )}
+
+
+ );
+}
diff --git a/www/app/lib/WherebyWebinarEmbed.tsx b/www/app/lib/WherebyWebinarEmbed.tsx
index 5bfef554..5526cca2 100644
--- a/www/app/lib/WherebyWebinarEmbed.tsx
+++ b/www/app/lib/WherebyWebinarEmbed.tsx
@@ -4,16 +4,16 @@ import "@whereby.com/browser-sdk/embed";
import { Box, Button, HStack, Text, Link } from "@chakra-ui/react";
import { toaster } from "../components/ui/toaster";
-interface WherebyEmbedProps {
+interface WherebyWebinarEmbedProps {
roomUrl: string;
onLeave?: () => void;
}
-// currently used for webinars only
+// used for webinars only
export default function WherebyWebinarEmbed({
roomUrl,
onLeave,
-}: WherebyEmbedProps) {
+}: WherebyWebinarEmbedProps) {
const wherebyRef = useRef(null);
// TODO extract common toast logic / styles to be used by consent toast on normal rooms
diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts
index 3b5eed2b..c5b4f9b9 100644
--- a/www/app/lib/apiHooks.ts
+++ b/www/app/lib/apiHooks.ts
@@ -12,7 +12,7 @@ import { useAuth } from "./AuthProvider";
* or, limitation or incorrect usage of .d type generator from json schema
* */
-const useAuthReady = () => {
+export const useAuthReady = () => {
const auth = useAuth();
return {
@@ -75,7 +75,7 @@ export function useTranscriptDelete() {
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", {
onSuccess: () => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: ["get", "/v1/transcripts/search"],
});
},
@@ -102,7 +102,7 @@ export function useTranscriptGet(transcriptId: string | null) {
{
params: {
path: {
- transcript_id: transcriptId || "",
+ transcript_id: transcriptId!,
},
},
},
@@ -120,7 +120,7 @@ export function useRoomGet(roomId: string | null) {
"/v1/rooms/{room_id}",
{
params: {
- path: { room_id: roomId || "" },
+ path: { room_id: roomId! },
},
},
{
@@ -145,7 +145,7 @@ export function useRoomCreate() {
return $api.useMutation("post", "/v1/rooms", {
onSuccess: () => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
@@ -188,7 +188,7 @@ export function useRoomDelete() {
return $api.useMutation("delete", "/v1/rooms/{room_id}", {
onSuccess: () => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
});
},
@@ -236,7 +236,7 @@ export function useTranscriptUpdate() {
return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", {
onSuccess: (data, variables) => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", {
params: {
path: { transcript_id: variables.params.path.transcript_id },
@@ -270,7 +270,7 @@ export function useTranscriptUploadAudio() {
"/v1/transcripts/{transcript_id}/record/upload",
{
onSuccess: (data, variables) => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}",
@@ -327,7 +327,7 @@ export function useTranscriptTopics(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/topics",
{
params: {
- path: { transcript_id: transcriptId || "" },
+ path: { transcript_id: transcriptId! },
},
},
{
@@ -344,7 +344,7 @@ export function useTranscriptTopicsWithWords(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/topics/with-words",
{
params: {
- path: { transcript_id: transcriptId || "" },
+ path: { transcript_id: transcriptId! },
},
},
{
@@ -365,8 +365,8 @@ export function useTranscriptTopicsWithWordsPerSpeaker(
{
params: {
path: {
- transcript_id: transcriptId || "",
- topic_id: topicId || "",
+ transcript_id: transcriptId!,
+ topic_id: topicId!,
},
},
},
@@ -384,7 +384,7 @@ export function useTranscriptParticipants(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/participants",
{
params: {
- path: { transcript_id: transcriptId || "" },
+ path: { transcript_id: transcriptId! },
},
},
{
@@ -402,7 +402,7 @@ export function useTranscriptParticipantUpdate() {
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
{
onSuccess: (data, variables) => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
@@ -430,7 +430,7 @@ export function useTranscriptParticipantCreate() {
"/v1/transcripts/{transcript_id}/participants",
{
onSuccess: (data, variables) => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
@@ -458,7 +458,7 @@ export function useTranscriptParticipantDelete() {
"/v1/transcripts/{transcript_id}/participants/{participant_id}",
{
onSuccess: (data, variables) => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: $api.queryOptions(
"get",
"/v1/transcripts/{transcript_id}/participants",
@@ -486,28 +486,30 @@ export function useTranscriptSpeakerAssign() {
"/v1/transcripts/{transcript_id}/speaker/assign",
{
onSuccess: (data, variables) => {
- queryClient.invalidateQueries({
- queryKey: $api.queryOptions(
- "get",
- "/v1/transcripts/{transcript_id}",
- {
- params: {
- path: { transcript_id: variables.params.path.transcript_id },
+ return Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
},
- },
- ).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: $api.queryOptions(
- "get",
- "/v1/transcripts/{transcript_id}/participants",
- {
- params: {
- path: { transcript_id: variables.params.path.transcript_id },
+ ).queryKey,
+ }),
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
},
- },
- ).queryKey,
- });
+ ).queryKey,
+ }),
+ ]);
},
onError: (error) => {
setError(error as Error, "There was an error assigning the speaker");
@@ -525,28 +527,30 @@ export function useTranscriptSpeakerMerge() {
"/v1/transcripts/{transcript_id}/speaker/merge",
{
onSuccess: (data, variables) => {
- queryClient.invalidateQueries({
- queryKey: $api.queryOptions(
- "get",
- "/v1/transcripts/{transcript_id}",
- {
- params: {
- path: { transcript_id: variables.params.path.transcript_id },
+ return Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
},
- },
- ).queryKey,
- });
- queryClient.invalidateQueries({
- queryKey: $api.queryOptions(
- "get",
- "/v1/transcripts/{transcript_id}/participants",
- {
- params: {
- path: { transcript_id: variables.params.path.transcript_id },
+ ).queryKey,
+ }),
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/transcripts/{transcript_id}/participants",
+ {
+ params: {
+ path: { transcript_id: variables.params.path.transcript_id },
+ },
},
- },
- ).queryKey,
- });
+ ).queryKey,
+ }),
+ ]);
},
onError: (error) => {
setError(error as Error, "There was an error merging speakers");
@@ -565,6 +569,29 @@ export function useMeetingAudioConsent() {
});
}
+export function useMeetingDeactivate() {
+ const { setError } = useError();
+ const queryClient = useQueryClient();
+
+ return $api.useMutation("patch", `/v1/meetings/{meeting_id}/deactivate`, {
+ onError: (error) => {
+ setError(error as Error, "Failed to end meeting");
+ },
+ onSuccess: () => {
+ return queryClient.invalidateQueries({
+ predicate: (query) => {
+ const key = query.queryKey;
+ return key.some(
+ (k) =>
+ typeof k === "string" &&
+ !!MEETING_LIST_PATH_PARTIALS.find((e) => k.includes(e)),
+ );
+ },
+ });
+ },
+ });
+}
+
export function useTranscriptWebRTC() {
const { setError } = useError();
@@ -585,7 +612,7 @@ export function useTranscriptCreate() {
return $api.useMutation("post", "/v1/transcripts", {
onSuccess: () => {
- queryClient.invalidateQueries({
+ return queryClient.invalidateQueries({
queryKey: ["get", "/v1/transcripts/search"],
});
},
@@ -600,13 +627,164 @@ export function useRoomsCreateMeeting() {
const queryClient = useQueryClient();
return $api.useMutation("post", "/v1/rooms/{room_name}/meeting", {
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
- });
+ onSuccess: async (data, variables) => {
+ const roomName = variables.params.path.room_name;
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions("get", "/v1/rooms").queryKey,
+ }),
+ queryClient.invalidateQueries({
+ queryKey: $api.queryOptions(
+ "get",
+ "/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`,
+ {
+ params: {
+ path: { room_name: roomName },
+ },
+ },
+ ).queryKey,
+ }),
+ ]);
},
onError: (error) => {
setError(error as Error, "There was an error creating the meeting");
},
});
}
+
+// Calendar integration hooks
+export function useRoomGetByName(roomName: string | null) {
+ return $api.useQuery(
+ "get",
+ "/v1/rooms/name/{room_name}",
+ {
+ params: {
+ path: { room_name: roomName! },
+ },
+ },
+ {
+ enabled: !!roomName,
+ },
+ );
+}
+
+export function useRoomUpcomingMeetings(roomName: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/rooms/{room_name}/meetings/upcoming" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_UPCOMING_PATH_PARTIAL}`,
+ {
+ params: {
+ path: { room_name: roomName! },
+ },
+ },
+ {
+ enabled: !!roomName && isAuthenticated,
+ },
+ );
+}
+
+const MEETINGS_PATH_PARTIAL = "meetings" as const;
+const MEETINGS_ACTIVE_PATH_PARTIAL = `${MEETINGS_PATH_PARTIAL}/active` as const;
+const MEETINGS_UPCOMING_PATH_PARTIAL =
+ `${MEETINGS_PATH_PARTIAL}/upcoming` as const;
+const MEETING_LIST_PATH_PARTIALS = [
+ MEETINGS_ACTIVE_PATH_PARTIAL,
+ MEETINGS_UPCOMING_PATH_PARTIAL,
+];
+
+export function useRoomActiveMeetings(roomName: string | null) {
+ return $api.useQuery(
+ "get",
+ "/v1/rooms/{room_name}/meetings/active" satisfies `/v1/rooms/{room_name}/${typeof MEETINGS_ACTIVE_PATH_PARTIAL}`,
+ {
+ params: {
+ path: { room_name: roomName! },
+ },
+ },
+ {
+ enabled: !!roomName,
+ },
+ );
+}
+
+export function useRoomGetMeeting(
+ roomName: string | null,
+ meetingId: string | null,
+) {
+ return $api.useQuery(
+ "get",
+ "/v1/rooms/{room_name}/meetings/{meeting_id}",
+ {
+ params: {
+ path: {
+ room_name: roomName!,
+ meeting_id: meetingId!,
+ },
+ },
+ },
+ {
+ enabled: !!roomName && !!meetingId,
+ },
+ );
+}
+
+export function useRoomJoinMeeting() {
+ const { setError } = useError();
+
+ return $api.useMutation(
+ "post",
+ "/v1/rooms/{room_name}/meetings/{meeting_id}/join",
+ {
+ onError: (error) => {
+ setError(error as Error, "There was an error joining the meeting");
+ },
+ },
+ );
+}
+
+export function useRoomIcsSync() {
+ const { setError } = useError();
+
+ return $api.useMutation("post", "/v1/rooms/{room_name}/ics/sync", {
+ onError: (error) => {
+ setError(error as Error, "There was an error syncing the calendar");
+ },
+ });
+}
+
+export function useRoomIcsStatus(roomName: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/rooms/{room_name}/ics/status",
+ {
+ params: {
+ path: { room_name: roomName! },
+ },
+ },
+ {
+ enabled: !!roomName && isAuthenticated,
+ },
+ );
+}
+
+export function useRoomCalendarEvents(roomName: string | null) {
+ const { isAuthenticated } = useAuthReady();
+
+ return $api.useQuery(
+ "get",
+ "/v1/rooms/{room_name}/meetings",
+ {
+ params: {
+ path: { room_name: roomName! },
+ },
+ },
+ {
+ enabled: !!roomName && isAuthenticated,
+ },
+ );
+}
+// End of Calendar integration hooks
diff --git a/www/app/lib/routes.ts b/www/app/lib/routes.ts
new file mode 100644
index 00000000..480082d0
--- /dev/null
+++ b/www/app/lib/routes.ts
@@ -0,0 +1,7 @@
+import { NonEmptyString } from "./utils";
+
+export const roomUrl = (roomName: NonEmptyString) => `/${roomName}`;
+export const roomMeetingUrl = (
+ roomName: NonEmptyString,
+ meetingId: NonEmptyString,
+) => `${roomUrl(roomName)}/${meetingId}`;
diff --git a/www/app/lib/routesClient.ts b/www/app/lib/routesClient.ts
new file mode 100644
index 00000000..9522bc74
--- /dev/null
+++ b/www/app/lib/routesClient.ts
@@ -0,0 +1,5 @@
+import { roomUrl } from "./routes";
+import { NonEmptyString } from "./utils";
+
+export const roomAbsoluteUrl = (roomName: NonEmptyString) =>
+ `${window.location.origin}${roomUrl(roomName)}`;
diff --git a/www/app/lib/timeUtils.ts b/www/app/lib/timeUtils.ts
new file mode 100644
index 00000000..db8a8152
--- /dev/null
+++ b/www/app/lib/timeUtils.ts
@@ -0,0 +1,25 @@
+export const formatDateTime = (d: Date): string => {
+ return d.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+};
+
+export const formatStartedAgo = (
+ startTime: Date,
+ now: Date = new Date(),
+): string => {
+ const diff = now.getTime() - startTime.getTime();
+
+ if (diff <= 0) return "Starting now";
+
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(minutes / 60);
+ const days = Math.floor(hours / 24);
+
+ if (days > 0) return `Started ${days}d ${hours % 24}h ${minutes % 60}m ago`;
+ if (hours > 0) return `Started ${hours}h ${minutes % 60}m ago`;
+ return `Started ${minutes} minutes ago`;
+};
diff --git a/www/app/lib/wherebyClient.ts b/www/app/lib/wherebyClient.ts
new file mode 100644
index 00000000..2345bd7b
--- /dev/null
+++ b/www/app/lib/wherebyClient.ts
@@ -0,0 +1,22 @@
+import { useEffect, useState } from "react";
+import { components } from "../reflector-api";
+
+export const useWhereby = () => {
+ const [wherebyLoaded, setWherebyLoaded] = useState(false);
+ useEffect(() => {
+ if (typeof window !== "undefined") {
+ import("@whereby.com/browser-sdk/embed")
+ .then(() => {
+ setWherebyLoaded(true);
+ })
+ .catch(console.error.bind(console));
+ }
+ }, []);
+ return wherebyLoaded;
+};
+
+export const getWherebyUrl = (
+ meeting: Pick,
+) =>
+ // host_room_url possible '' atm
+ meeting.host_room_url || meeting.room_url;
diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts
index 2b92f4d4..e1709d69 100644
--- a/www/app/reflector-api.d.ts
+++ b/www/app/reflector-api.d.ts
@@ -41,6 +41,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/v1/meetings/{meeting_id}/deactivate": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ /** Meeting Deactivate */
+ patch: operations["v1_meeting_deactivate"];
+ trace?: never;
+ };
"/v1/rooms": {
parameters: {
query?: never;
@@ -78,6 +95,23 @@ export interface paths {
patch: operations["v1_rooms_update"];
trace?: never;
};
+ "/v1/rooms/name/{room_name}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Rooms Get By Name */
+ get: operations["v1_rooms_get_by_name"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/v1/rooms/{room_name}/meeting": {
parameters: {
query?: never;
@@ -115,6 +149,128 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/v1/rooms/{room_name}/ics/sync": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Rooms Sync Ics */
+ post: operations["v1_rooms_sync_ics"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms/{room_name}/ics/status": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Rooms Ics Status */
+ get: operations["v1_rooms_ics_status"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms/{room_name}/meetings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Rooms List Meetings */
+ get: operations["v1_rooms_list_meetings"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms/{room_name}/meetings/upcoming": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Rooms List Upcoming Meetings */
+ get: operations["v1_rooms_list_upcoming_meetings"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms/{room_name}/meetings/active": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Rooms List Active Meetings */
+ get: operations["v1_rooms_list_active_meetings"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms/{room_name}/meetings/{meeting_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Rooms Get Meeting
+ * @description Get a single meeting by ID within a specific room.
+ */
+ get: operations["v1_rooms_get_meeting"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/rooms/{room_name}/meetings/{meeting_id}/join": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Rooms Join Meeting */
+ post: operations["v1_rooms_join_meeting"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/v1/transcripts": {
parameters: {
query?: never;
@@ -505,6 +661,52 @@ export interface components {
*/
chunk: string;
};
+ /** CalendarEventResponse */
+ CalendarEventResponse: {
+ /** Id */
+ id: string;
+ /** Room Id */
+ room_id: string;
+ /** Ics Uid */
+ ics_uid: string;
+ /** Title */
+ title?: string | null;
+ /** Description */
+ description?: string | null;
+ /**
+ * Start Time
+ * Format: date-time
+ */
+ start_time: string;
+ /**
+ * End Time
+ * Format: date-time
+ */
+ end_time: string;
+ /** Attendees */
+ attendees?:
+ | {
+ [key: string]: unknown;
+ }[]
+ | null;
+ /** Location */
+ location?: string | null;
+ /**
+ * Last Synced
+ * Format: date-time
+ */
+ last_synced: string;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /**
+ * Updated At
+ * Format: date-time
+ */
+ updated_at: string;
+ };
/** CreateParticipant */
CreateParticipant: {
/** Speaker */
@@ -536,6 +738,26 @@ export interface components {
webhook_url: string;
/** Webhook Secret */
webhook_secret: string;
+ /** Ics Url */
+ ics_url?: string | null;
+ /**
+ * Ics Fetch Interval
+ * @default 300
+ */
+ ics_fetch_interval: number;
+ /**
+ * Ics Enabled
+ * @default false
+ */
+ ics_enabled: boolean;
+ };
+ /** CreateRoomMeeting */
+ CreateRoomMeeting: {
+ /**
+ * Allow Duplicated
+ * @default false
+ */
+ allow_duplicated: boolean | null;
};
/** CreateTranscript */
CreateTranscript: {
@@ -748,6 +970,60 @@ export interface components {
/** Detail */
detail?: components["schemas"]["ValidationError"][];
};
+ /** ICSStatus */
+ ICSStatus: {
+ /**
+ * Status
+ * @enum {string}
+ */
+ status: "enabled" | "disabled";
+ /** Last Sync */
+ last_sync?: string | null;
+ /** Next Sync */
+ next_sync?: string | null;
+ /** Last Etag */
+ last_etag?: string | null;
+ /**
+ * Events Count
+ * @default 0
+ */
+ events_count: number;
+ };
+ /** ICSSyncResult */
+ ICSSyncResult: {
+ status: components["schemas"]["SyncStatus"];
+ /** Hash */
+ hash?: string | null;
+ /**
+ * Events Found
+ * @default 0
+ */
+ events_found: number;
+ /**
+ * Total Events
+ * @default 0
+ */
+ total_events: number;
+ /**
+ * Events Created
+ * @default 0
+ */
+ events_created: number;
+ /**
+ * Events Updated
+ * @default 0
+ */
+ events_updated: number;
+ /**
+ * Events Deleted
+ * @default 0
+ */
+ events_deleted: number;
+ /** Error */
+ error?: string | null;
+ /** Reason */
+ reason?: string | null;
+ };
/** Meeting */
Meeting: {
/** Id */
@@ -768,12 +1044,53 @@ export interface components {
* Format: date-time
*/
end_date: string;
+ /** User Id */
+ user_id?: string | null;
+ /** Room Id */
+ room_id?: string | null;
+ /**
+ * Is Locked
+ * @default false
+ */
+ is_locked: boolean;
+ /**
+ * Room Mode
+ * @default normal
+ * @enum {string}
+ */
+ room_mode: "normal" | "group";
/**
* Recording Type
* @default cloud
* @enum {string}
*/
recording_type: "none" | "local" | "cloud";
+ /**
+ * Recording Trigger
+ * @default automatic-2nd-participant
+ * @enum {string}
+ */
+ recording_trigger:
+ | "none"
+ | "prompt"
+ | "automatic"
+ | "automatic-2nd-participant";
+ /**
+ * Num Clients
+ * @default 0
+ */
+ num_clients: number;
+ /**
+ * Is Active
+ * @default true
+ */
+ is_active: boolean;
+ /** Calendar Event Id */
+ calendar_event_id?: string | null;
+ /** Calendar Metadata */
+ calendar_metadata?: {
+ [key: string]: unknown;
+ } | null;
};
/** MeetingConsentRequest */
MeetingConsentRequest: {
@@ -844,6 +1161,22 @@ export interface components {
recording_trigger: string;
/** Is Shared */
is_shared: boolean;
+ /** Ics Url */
+ ics_url?: string | null;
+ /**
+ * Ics Fetch Interval
+ * @default 300
+ */
+ ics_fetch_interval: number;
+ /**
+ * Ics Enabled
+ * @default false
+ */
+ ics_enabled: boolean;
+ /** Ics Last Sync */
+ ics_last_sync?: string | null;
+ /** Ics Last Etag */
+ ics_last_etag?: string | null;
};
/** RoomDetails */
RoomDetails: {
@@ -874,6 +1207,22 @@ export interface components {
recording_trigger: string;
/** Is Shared */
is_shared: boolean;
+ /** Ics Url */
+ ics_url?: string | null;
+ /**
+ * Ics Fetch Interval
+ * @default 300
+ */
+ ics_fetch_interval: number;
+ /**
+ * Ics Enabled
+ * @default false
+ */
+ ics_enabled: boolean;
+ /** Ics Last Sync */
+ ics_last_sync?: string | null;
+ /** Ics Last Etag */
+ ics_last_etag?: string | null;
/** Webhook Url */
webhook_url: string | null;
/** Webhook Secret */
@@ -998,6 +1347,11 @@ export interface components {
/** Name */
name: string;
};
+ /**
+ * SyncStatus
+ * @enum {string}
+ */
+ SyncStatus: "success" | "unchanged" | "error" | "skipped";
/** Topic */
Topic: {
/** Name */
@@ -1022,27 +1376,33 @@ export interface components {
/** UpdateRoom */
UpdateRoom: {
/** Name */
- name: string;
+ name?: string | null;
/** Zulip Auto Post */
- zulip_auto_post: boolean;
+ zulip_auto_post?: boolean | null;
/** Zulip Stream */
- zulip_stream: string;
+ zulip_stream?: string | null;
/** Zulip Topic */
- zulip_topic: string;
+ zulip_topic?: string | null;
/** Is Locked */
- is_locked: boolean;
+ is_locked?: boolean | null;
/** Room Mode */
- room_mode: string;
+ room_mode?: string | null;
/** Recording Type */
- recording_type: string;
+ recording_type?: string | null;
/** Recording Trigger */
- recording_trigger: string;
+ recording_trigger?: string | null;
/** Is Shared */
- is_shared: boolean;
+ is_shared?: boolean | null;
/** Webhook Url */
- webhook_url: string;
+ webhook_url?: string | null;
/** Webhook Secret */
- webhook_secret: string;
+ webhook_secret?: string | null;
+ /** Ics Url */
+ ics_url?: string | null;
+ /** Ics Fetch Interval */
+ ics_fetch_interval?: number | null;
+ /** Ics Enabled */
+ ics_enabled?: boolean | null;
};
/** UpdateTranscript */
UpdateTranscript: {
@@ -1204,6 +1564,37 @@ export interface operations {
};
};
};
+ v1_meeting_deactivate: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ meeting_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
v1_rooms_list: {
parameters: {
query?: {
@@ -1368,7 +1759,7 @@ export interface operations {
};
};
};
- v1_rooms_create_meeting: {
+ v1_rooms_get_by_name: {
parameters: {
query?: never;
header?: never;
@@ -1378,6 +1769,41 @@ export interface operations {
cookie?: never;
};
requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["RoomDetails"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_create_meeting: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateRoomMeeting"];
+ };
+ };
responses: {
/** @description Successful Response */
200: {
@@ -1430,6 +1856,227 @@ export interface operations {
};
};
};
+ v1_rooms_sync_ics: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ICSSyncResult"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_ics_status: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["ICSStatus"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_list_meetings: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CalendarEventResponse"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_list_upcoming_meetings: {
+ parameters: {
+ query?: {
+ minutes_ahead?: number;
+ };
+ header?: never;
+ path: {
+ room_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CalendarEventResponse"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_list_active_meetings: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_name: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Meeting"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_get_meeting: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_name: string;
+ meeting_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Meeting"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_rooms_join_meeting: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ room_name: string;
+ meeting_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["Meeting"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
v1_transcripts_list: {
parameters: {
query?: {
diff --git a/www/app/webinars/[title]/page.tsx b/www/app/webinars/[title]/page.tsx
index 51583a2a..ff21af1e 100644
--- a/www/app/webinars/[title]/page.tsx
+++ b/www/app/webinars/[title]/page.tsx
@@ -3,7 +3,7 @@ import { useEffect, useState, use } from "react";
import Link from "next/link";
import Image from "next/image";
import { notFound } from "next/navigation";
-import useRoomMeeting from "../../[roomName]/useRoomMeeting";
+import useRoomDefaultMeeting from "../../[roomName]/useRoomDefaultMeeting";
import dynamic from "next/dynamic";
const WherebyEmbed = dynamic(() => import("../../lib/WherebyWebinarEmbed"), {
ssr: false,
@@ -72,7 +72,7 @@ export default function WebinarPage(details: WebinarDetails) {
const startDate = new Date(Date.parse(webinar.startsAt));
const endDate = new Date(Date.parse(webinar.endsAt));
- const meeting = useRoomMeeting(ROOM_NAME);
+ const meeting = useRoomDefaultMeeting(ROOM_NAME);
const roomUrl = meeting?.response?.host_room_url
? meeting?.response?.host_room_url
: meeting?.response?.room_url;
diff --git a/www/package.json b/www/package.json
index d53c1536..c93a9554 100644
--- a/www/package.json
+++ b/www/package.json
@@ -45,6 +45,7 @@
"react-qr-code": "^2.0.12",
"react-select-search": "^4.1.7",
"redlock": "5.0.0-beta.2",
+ "remeda": "^2.31.1",
"sass": "^1.63.6",
"simple-peer": "^9.11.1",
"tailwindcss": "^3.3.2",
diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml
index a4e78972..6c0a3d83 100644
--- a/www/pnpm-lock.yaml
+++ b/www/pnpm-lock.yaml
@@ -106,6 +106,9 @@ importers:
redlock:
specifier: 5.0.0-beta.2
version: 5.0.0-beta.2
+ remeda:
+ specifier: ^2.31.1
+ version: 2.31.1
sass:
specifier: ^1.63.6
version: 1.90.0
@@ -7645,6 +7648,12 @@ packages:
integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==,
}
+ remeda@2.31.1:
+ resolution:
+ {
+ integrity: sha512-FRZefcuXbmCoYt8hAITAzW4t8i/RERaGk/+GtRN90eV3NHxsnRKCDIOJVrwrQ6zz77TG/Xyi9mGRfiJWT7DK1g==,
+ }
+
require-directory@2.1.1:
resolution:
{
@@ -14510,6 +14519,10 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
+ remeda@2.31.1:
+ dependencies:
+ type-fest: 4.41.0
+
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
From 396a95d5cef54d24535ccff1b9d9a8cbc0e52d12 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Wed, 17 Sep 2025 16:44:11 -0600
Subject: [PATCH 38/77] chore(main): release 0.12.0 (#654)
---
CHANGELOG.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e59f1ab6..9d52ffff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [0.12.0](https://github.com/Monadical-SAS/reflector/compare/v0.11.0...v0.12.0) (2025-09-17)
+
+
+### Features
+
+* calendar integration ([#608](https://github.com/Monadical-SAS/reflector/issues/608)) ([6f680b5](https://github.com/Monadical-SAS/reflector/commit/6f680b57954c688882c4ed49f40f161c52a00a24))
+* self-hosted gpu api ([#636](https://github.com/Monadical-SAS/reflector/issues/636)) ([ab859d6](https://github.com/Monadical-SAS/reflector/commit/ab859d65a6bded904133a163a081a651b3938d42))
+
+
+### Bug Fixes
+
+* ignore player hotkeys for text inputs ([#646](https://github.com/Monadical-SAS/reflector/issues/646)) ([fa049e8](https://github.com/Monadical-SAS/reflector/commit/fa049e8d068190ce7ea015fd9fcccb8543f54a3f))
+
## [0.11.0](https://github.com/Monadical-SAS/reflector/compare/v0.10.0...v0.11.0) (2025-09-16)
From 870e8605171a27155a9cbee215eeccb9a8d6c0a2 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Wed, 17 Sep 2025 17:09:54 -0600
Subject: [PATCH 39/77] fix: production blocked because having existing meeting
with room_id null (#657)
---
.../6dec9fb5b46c_make_meeting_room_id_required_and_add_.py | 3 ---
1 file changed, 3 deletions(-)
diff --git a/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py b/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py
index 20828c65..c0a29246 100644
--- a/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py
+++ b/server/migrations/versions/6dec9fb5b46c_make_meeting_room_id_required_and_add_.py
@@ -8,7 +8,6 @@ Create Date: 2025-09-10 10:47:06.006819
from typing import Sequence, Union
-import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
@@ -21,7 +20,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("meeting", schema=None) as batch_op:
- batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=False)
batch_op.create_foreign_key(
None, "room", ["room_id"], ["id"], ondelete="CASCADE"
)
@@ -33,6 +31,5 @@ def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("meeting", schema=None) as batch_op:
batch_op.drop_constraint("meeting_room_id_fkey", type_="foreignkey")
- batch_op.alter_column("room_id", existing_type=sa.VARCHAR(), nullable=True)
# ### end Alembic commands ###
From 6566e04300d56897587375020e7099e47328bbd6 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Wed, 17 Sep 2025 17:17:22 -0600
Subject: [PATCH 40/77] chore(main): release 0.12.1 (#658)
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d52ffff..9933ba7f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.12.1](https://github.com/Monadical-SAS/reflector/compare/v0.12.0...v0.12.1) (2025-09-17)
+
+
+### Bug Fixes
+
+* production blocked because having existing meeting with room_id null ([#657](https://github.com/Monadical-SAS/reflector/issues/657)) ([870e860](https://github.com/Monadical-SAS/reflector/commit/870e8605171a27155a9cbee215eeccb9a8d6c0a2))
+
## [0.12.0](https://github.com/Monadical-SAS/reflector/compare/v0.11.0...v0.12.0) (2025-09-17)
From 2b723da08bd8f1e037cb769285abd3d57463905a Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Wed, 17 Sep 2025 20:02:17 -0400
Subject: [PATCH 41/77] rooms-page-calendar-ics-room-name-fix (#659)
Co-authored-by: Igor Loskutov
---
.../(app)/rooms/_components/ICSSettings.tsx | 54 ++++++++++---------
www/app/(app)/rooms/page.tsx | 2 +-
2 files changed, 29 insertions(+), 27 deletions(-)
diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx
index 1fa97692..9b45ff33 100644
--- a/www/app/(app)/rooms/_components/ICSSettings.tsx
+++ b/www/app/(app)/rooms/_components/ICSSettings.tsx
@@ -27,7 +27,7 @@ import {
} from "../../../lib/utils";
interface ICSSettingsProps {
- roomName: NonEmptyString;
+ roomName: NonEmptyString | null;
icsUrl?: string;
icsEnabled?: boolean;
icsFetchInterval?: number;
@@ -85,7 +85,7 @@ export default function ICSSettings({
const handleCopyRoomUrl = async () => {
try {
await navigator.clipboard.writeText(
- roomAbsoluteUrl(assertExistsAndNonEmptyString(roomName)),
+ roomAbsoluteUrl(assertExists(roomName)),
);
setJustCopied(true);
@@ -123,7 +123,7 @@ export default function ICSSettings({
const handleRoomUrlClick = () => {
if (roomUrlInputRef.current) {
roomUrlInputRef.current.select();
- handleCopyRoomUrl();
+ handleCopyRoomUrl().then(() => {});
}
};
@@ -196,30 +196,32 @@ export default function ICSSettings({
To enable Reflector to recognize your calendar events as meetings,
add this URL as the location in your calendar events
-
-
-
-
- {justCopied ? : }
-
+ {roomName ? (
+
+
+
+
+ {justCopied ? : }
+
+
-
+ ) : null}
diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx
index 88e66720..9de5950a 100644
--- a/www/app/(app)/rooms/page.tsx
+++ b/www/app/(app)/rooms/page.tsx
@@ -624,7 +624,7 @@ export default function RoomsList() {
Date: Thu, 18 Sep 2025 10:02:30 -0600
Subject: [PATCH 42/77] fix: invalid cleanup call (#660)
---
server/reflector/worker/cleanup.py | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/server/reflector/worker/cleanup.py b/server/reflector/worker/cleanup.py
index e634994d..66d45e94 100644
--- a/server/reflector/worker/cleanup.py
+++ b/server/reflector/worker/cleanup.py
@@ -5,7 +5,6 @@ Deletes old anonymous transcripts and their associated meetings/recordings.
Transcripts are the main entry point - any associated data is also removed.
"""
-import asyncio
from datetime import datetime, timedelta, timezone
from typing import TypedDict
@@ -152,5 +151,5 @@ async def cleanup_old_public_data(
retry_kwargs={"max_retries": 3, "countdown": 300},
)
@asynctask
-def cleanup_old_public_data_task(days: int | None = None):
- asyncio.run(cleanup_old_public_data(days=days))
+async def cleanup_old_public_data_task(days: int | None = None):
+ await cleanup_old_public_data(days=days)
From 47716f6e5ddee952609d2fa0ffabdfa865286796 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Fri, 19 Sep 2025 15:14:40 -0400
Subject: [PATCH 43/77] feat: room form edit with enter (#662)
* room form edit with enter
* mobile form enter do nothing
* restore overwritten older change
---------
Co-authored-by: Igor Loskutov
---
www/app/(app)/rooms/page.tsx | 799 ++++++++++++++++++-----------------
1 file changed, 406 insertions(+), 393 deletions(-)
diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx
index 9de5950a..723d698a 100644
--- a/www/app/(app)/rooms/page.tsx
+++ b/www/app/(app)/rooms/page.tsx
@@ -309,7 +309,7 @@ export default function RoomsList() {
setRoomInput(null);
setIsEditing(false);
- setEditRoomId("");
+ setEditRoomId(null);
setNameError("");
refetch();
onClose();
@@ -449,415 +449,428 @@ export default function RoomsList() {
-
-
- General
- Calendar
- Share
- WebHook
-
+
+ {showWebhookSecret ? : }
+
)}
- >
- )}
- >
- )}
-
-
+
+ Used for HMAC signature verification (auto-generated
+ if left empty)
+
+
+
+ {isEditing && (
+ <>
+
+
+ {testingWebhook ? (
+ <>
+
+ Testing...
+ >
+ ) : (
+ "Test Webhook"
+ )}
+
+ {webhookTestResult && (
+
+ {webhookTestResult}
+
+ )}
+
+ >
+ )}
+ >
+ )}
+
+
+
+
+ {
+ setRoomInput({
+ ...room,
+ icsUrl:
+ settings.ics_url !== undefined
+ ? settings.ics_url
+ : room.icsUrl,
+ icsEnabled:
+ settings.ics_enabled !== undefined
+ ? settings.ics_enabled
+ : room.icsEnabled,
+ icsFetchInterval:
+ settings.ics_fetch_interval !== undefined
+ ? settings.ics_fetch_interval
+ : room.icsFetchInterval,
+ });
+ }}
+ isOwner={true}
+ isEditing={isEditing}
+ />
+
+
+
+
Cancel
Date: Sun, 21 Sep 2025 20:50:47 -0600
Subject: [PATCH 44/77] chore(main): release 0.13.0 (#661)
---
CHANGELOG.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9933ba7f..23192933 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## [0.13.0](https://github.com/Monadical-SAS/reflector/compare/v0.12.1...v0.13.0) (2025-09-19)
+
+
+### Features
+
+* room form edit with enter ([#662](https://github.com/Monadical-SAS/reflector/issues/662)) ([47716f6](https://github.com/Monadical-SAS/reflector/commit/47716f6e5ddee952609d2fa0ffabdfa865286796))
+
+
+### Bug Fixes
+
+* invalid cleanup call ([#660](https://github.com/Monadical-SAS/reflector/issues/660)) ([0abcebf](https://github.com/Monadical-SAS/reflector/commit/0abcebfc9491f87f605f21faa3e53996fafedd9a))
+
## [0.12.1](https://github.com/Monadical-SAS/reflector/compare/v0.12.0...v0.12.1) (2025-09-17)
From 27016e6051bb5510c52327fa5eff85fd30ac6f80 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Mon, 22 Sep 2025 13:38:23 -0400
Subject: [PATCH 45/77] minimum release age for npm (#665)
Co-authored-by: Igor Loskutov
---
www/.npmrc | 1 +
1 file changed, 1 insertion(+)
create mode 100644 www/.npmrc
diff --git a/www/.npmrc b/www/.npmrc
new file mode 100644
index 00000000..052f58b5
--- /dev/null
+++ b/www/.npmrc
@@ -0,0 +1 @@
+minimum-release-age=1440 #24hr in minutes
\ No newline at end of file
From 565a62900f5a02fc946b68f9269a42190ed70ab6 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Mon, 22 Sep 2025 16:45:28 -0600
Subject: [PATCH 46/77] fix: TypeError on not all arguments converted during
string formatting in logger (#667)
---
server/reflector/worker/process.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py
index 8c885d14..e660e840 100644
--- a/server/reflector/worker/process.py
+++ b/server/reflector/worker/process.py
@@ -213,7 +213,6 @@ async def process_meetings():
should_deactivate = True
logger_.info(
"Meeting deactivated - scheduled time ended with no participants",
- meeting.id,
)
else:
logger_.debug("Meeting not yet started, keep it")
@@ -224,8 +223,8 @@ async def process_meetings():
processed_count += 1
- except Exception as e:
- logger_.error(f"Error processing meeting", exc_info=True)
+ except Exception:
+ logger_.error("Error processing meeting", exc_info=True)
finally:
try:
lock.release()
@@ -233,7 +232,7 @@ async def process_meetings():
pass # Lock already released or expired
logger.info(
- f"Processed meetings finished",
+ "Processed meetings finished",
processed_count=processed_count,
skipped_count=skipped_count,
)
From 0aaa42528a5e7c9cbdca398a54cb7428b316b359 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Mon, 22 Sep 2025 16:47:44 -0600
Subject: [PATCH 47/77] chore(main): release 0.13.1 (#668)
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23192933..83c58a06 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22)
+
+
+### Bug Fixes
+
+* TypeError on not all arguments converted during string formatting in logger ([#667](https://github.com/Monadical-SAS/reflector/issues/667)) ([565a629](https://github.com/Monadical-SAS/reflector/commit/565a62900f5a02fc946b68f9269a42190ed70ab6))
+
## [0.13.0](https://github.com/Monadical-SAS/reflector/compare/v0.12.1...v0.13.0) (2025-09-19)
From 5bf64b5a41f64535e22849b4bb11734d4dbb4aae Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Wed, 24 Sep 2025 11:15:27 -0400
Subject: [PATCH 48/77] feat: docker-compose for production frontend (#664)
* docker-compose for production frontend
* fix: Remove external Redis port mapping for Coolify compatibility
Redis should only be accessible within the internal Docker network in Coolify deployments to avoid port conflicts with other applications.
* fix: Remove external port mapping for web service in Coolify
Coolify handles port exposure through its proxy (Traefik), so services should not expose ports directly in the docker-compose file.
* server side client envs
* missing vars
* nextjs experimental
* fix claude 'fix'
* remove build env vars compose
* docker
* remove ports for coolify
* review
* cleanup
---------
Co-authored-by: Igor Loskutov
---
CLAUDE.md | 2 +-
README.md | 25 ++++--
docker-compose.prod.yml | 39 ++++++++
compose.yml => docker-compose.yml | 4 +-
server/reflector/app.py | 6 ++
www/.dockerignore | 14 +++
www/.env.example | 22 ++---
www/DOCKER_README.md | 81 +++++++++++++++++
www/Dockerfile | 9 +-
.../(app)/rooms/_components/ICSSettings.tsx | 8 +-
www/app/(app)/rooms/_components/RoomTable.tsx | 30 ++++++-
www/app/(app)/rooms/page.tsx | 8 +-
www/app/[roomName]/room.tsx | 15 +++-
www/app/api/health/route.ts | 38 ++++++++
www/app/layout.tsx | 32 +++----
www/app/lib/apiClient.tsx | 16 ++--
www/app/lib/authBackend.ts | 34 +++----
www/app/lib/clientEnv.ts | 88 +++++++++++++++++++
www/app/lib/features.ts | 19 ++--
www/app/lib/nextBuild.ts | 17 ++++
www/app/lib/utils.ts | 21 +++--
www/app/providers.tsx | 11 ++-
www/package.json | 1 +
23 files changed, 448 insertions(+), 92 deletions(-)
create mode 100644 docker-compose.prod.yml
rename compose.yml => docker-compose.yml (94%)
create mode 100644 www/.dockerignore
create mode 100644 www/DOCKER_README.md
create mode 100644 www/app/api/health/route.ts
create mode 100644 www/app/lib/clientEnv.ts
create mode 100644 www/app/lib/nextBuild.ts
diff --git a/CLAUDE.md b/CLAUDE.md
index 22a99171..202fba4c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -151,7 +151,7 @@ All endpoints prefixed `/v1/`:
**Frontend** (`www/.env`):
- `NEXTAUTH_URL`, `NEXTAUTH_SECRET` - Authentication configuration
-- `NEXT_PUBLIC_REFLECTOR_API_URL` - Backend API endpoint
+- `REFLECTOR_API_URL` - Backend API endpoint
- `REFLECTOR_DOMAIN_CONFIG` - Feature flags and domain settings
## Testing Strategy
diff --git a/README.md b/README.md
index ebb91fcb..d6bdb86e 100644
--- a/README.md
+++ b/README.md
@@ -168,6 +168,13 @@ You can manually process an audio file by calling the process tool:
uv run python -m reflector.tools.process path/to/audio.wav
```
+## 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.
+
+Instead, all the variables are runtime. Variables needed to the frontend are served to the frontend app at initial render.
+
+It also means there's no static prebuild and no static files to serve for js/html.
## Feature Flags
@@ -177,24 +184,24 @@ Reflector uses environment variable-based feature flags to control application f
| Feature Flag | Environment Variable |
|-------------|---------------------|
-| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` |
-| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` |
-| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` |
-| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` |
-| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` |
+| `requireLogin` | `FEATURE_REQUIRE_LOGIN` |
+| `privacy` | `FEATURE_PRIVACY` |
+| `browse` | `FEATURE_BROWSE` |
+| `sendToZulip` | `FEATURE_SEND_TO_ZULIP` |
+| `rooms` | `FEATURE_ROOMS` |
### Setting Feature Flags
-Feature flags are controlled via environment variables using the pattern `NEXT_PUBLIC_FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
+Feature flags are controlled via environment variables using the pattern `FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
**Examples:**
```bash
# Enable user authentication requirement
-NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
+FEATURE_REQUIRE_LOGIN=true
# Disable browse functionality
-NEXT_PUBLIC_FEATURE_BROWSE=false
+FEATURE_BROWSE=false
# Enable Zulip integration
-NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
+FEATURE_SEND_TO_ZULIP=true
```
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
new file mode 100644
index 00000000..9b032e40
--- /dev/null
+++ b/docker-compose.prod.yml
@@ -0,0 +1,39 @@
+# Production Docker Compose configuration for Frontend
+# Usage: docker compose -f docker-compose.prod.yml up -d
+
+services:
+ web:
+ build:
+ context: ./www
+ dockerfile: Dockerfile
+ image: reflector-frontend:latest
+ environment:
+ - KV_URL=${KV_URL:-redis://redis:6379}
+ - SITE_URL=${SITE_URL}
+ - API_URL=${API_URL}
+ - WEBSOCKET_URL=${WEBSOCKET_URL}
+ - NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
+ - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-changeme-in-production}
+ - AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
+ - AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
+ - AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
+ - AUTHENTIK_REFRESH_TOKEN_URL=${AUTHENTIK_REFRESH_TOKEN_URL}
+ - SENTRY_DSN=${SENTRY_DSN}
+ - SENTRY_IGNORE_API_RESOLUTION_ERROR=${SENTRY_IGNORE_API_RESOLUTION_ERROR:-1}
+ depends_on:
+ - redis
+ restart: unless-stopped
+
+ redis:
+ image: redis:7.2-alpine
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ volumes:
+ - redis_data:/data
+
+volumes:
+ redis_data:
\ No newline at end of file
diff --git a/compose.yml b/docker-compose.yml
similarity index 94%
rename from compose.yml
rename to docker-compose.yml
index acbfd3b5..2fd4543d 100644
--- a/compose.yml
+++ b/docker-compose.yml
@@ -39,7 +39,7 @@ services:
ports:
- 6379:6379
web:
- image: node:18
+ image: node:22-alpine
ports:
- "3000:3000"
command: sh -c "corepack enable && pnpm install && pnpm dev"
@@ -50,6 +50,8 @@ services:
- /app/node_modules
env_file:
- ./www/.env.local
+ environment:
+ - NODE_ENV=development
postgres:
image: postgres:17
diff --git a/server/reflector/app.py b/server/reflector/app.py
index e1d07d20..609474a2 100644
--- a/server/reflector/app.py
+++ b/server/reflector/app.py
@@ -65,6 +65,12 @@ app.add_middleware(
allow_headers=["*"],
)
+
+@app.get("/health")
+async def health():
+ return {"status": "healthy"}
+
+
# metrics
instrumentator = Instrumentator(
excluded_handlers=["/docs", "/metrics"],
diff --git a/www/.dockerignore b/www/.dockerignore
new file mode 100644
index 00000000..c2d061c7
--- /dev/null
+++ b/www/.dockerignore
@@ -0,0 +1,14 @@
+.env
+.env.*
+.env.local
+.env.development
+.env.production
+node_modules
+.next
+.git
+.gitignore
+*.md
+.DS_Store
+coverage
+.pnpm-store
+*.log
\ No newline at end of file
diff --git a/www/.env.example b/www/.env.example
index 77017d91..da46b513 100644
--- a/www/.env.example
+++ b/www/.env.example
@@ -1,9 +1,5 @@
-# Environment
-ENVIRONMENT=development
-NEXT_PUBLIC_ENV=development
-
# Site Configuration
-NEXT_PUBLIC_SITE_URL=http://localhost:3000
+SITE_URL=http://localhost:3000
# Nextauth envs
# not used in app code but in lib code
@@ -18,16 +14,16 @@ AUTHENTIK_CLIENT_ID=your-client-id-here
AUTHENTIK_CLIENT_SECRET=your-client-secret-here
# Feature Flags
-# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
-# NEXT_PUBLIC_FEATURE_PRIVACY=false
-# NEXT_PUBLIC_FEATURE_BROWSE=true
-# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
-# NEXT_PUBLIC_FEATURE_ROOMS=true
+# FEATURE_REQUIRE_LOGIN=true
+# FEATURE_PRIVACY=false
+# FEATURE_BROWSE=true
+# FEATURE_SEND_TO_ZULIP=true
+# FEATURE_ROOMS=true
# API URLs
-NEXT_PUBLIC_API_URL=http://127.0.0.1:1250
-NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250
-NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
+API_URL=http://127.0.0.1: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
diff --git a/www/DOCKER_README.md b/www/DOCKER_README.md
new file mode 100644
index 00000000..59d4c8ac
--- /dev/null
+++ b/www/DOCKER_README.md
@@ -0,0 +1,81 @@
+# Docker Production Build Guide
+
+## Overview
+
+The Docker image builds without any environment variables and requires all configuration to be provided at runtime.
+
+## Environment Variables (ALL Runtime)
+
+### Required Runtime Variables
+
+```bash
+API_URL # Backend API URL (e.g., https://api.example.com)
+WEBSOCKET_URL # WebSocket URL (e.g., wss://api.example.com)
+NEXTAUTH_URL # NextAuth base URL (e.g., https://app.example.com)
+NEXTAUTH_SECRET # Random secret for NextAuth (generate with: openssl rand -base64 32)
+KV_URL # Redis URL (e.g., redis://redis:6379)
+```
+
+### Optional Runtime Variables
+
+```bash
+SITE_URL # Frontend URL (defaults to NEXTAUTH_URL)
+
+AUTHENTIK_ISSUER # OAuth issuer URL
+AUTHENTIK_CLIENT_ID # OAuth client ID
+AUTHENTIK_CLIENT_SECRET # OAuth client secret
+AUTHENTIK_REFRESH_TOKEN_URL # OAuth token refresh URL
+
+FEATURE_REQUIRE_LOGIN=false # Require authentication
+FEATURE_PRIVACY=true # Enable privacy features
+FEATURE_BROWSE=true # Enable browsing features
+FEATURE_SEND_TO_ZULIP=false # Enable Zulip integration
+FEATURE_ROOMS=true # Enable rooms feature
+
+SENTRY_DSN # Sentry error tracking
+AUTH_CALLBACK_URL # OAuth callback URL
+```
+
+## Building the Image
+
+### Option 1: Using Docker Compose
+
+1. Build the image (no environment variables needed):
+
+```bash
+docker compose -f docker-compose.prod.yml build
+```
+
+2. Create a `.env` file with runtime variables
+
+3. Run with environment variables:
+
+```bash
+docker compose -f docker-compose.prod.yml --env-file .env up -d
+```
+
+### Option 2: Using Docker CLI
+
+1. Build the image (no build args):
+
+```bash
+docker build -t reflector-frontend:latest ./www
+```
+
+2. Run with environment variables:
+
+```bash
+docker run -d \
+ -p 3000:3000 \
+ -e API_URL=https://api.example.com \
+ -e WEBSOCKET_URL=wss://api.example.com \
+ -e NEXTAUTH_URL=https://app.example.com \
+ -e NEXTAUTH_SECRET=your-secret \
+ -e KV_URL=redis://redis:6379 \
+ -e AUTHENTIK_ISSUER=https://auth.example.com/application/o/reflector \
+ -e AUTHENTIK_CLIENT_ID=your-client-id \
+ -e AUTHENTIK_CLIENT_SECRET=your-client-secret \
+ -e AUTHENTIK_REFRESH_TOKEN_URL=https://auth.example.com/application/o/token/ \
+ -e FEATURE_REQUIRE_LOGIN=true \
+ reflector-frontend:latest
+```
diff --git a/www/Dockerfile b/www/Dockerfile
index 68c23e33..65729046 100644
--- a/www/Dockerfile
+++ b/www/Dockerfile
@@ -24,7 +24,8 @@ COPY --link . .
ENV NEXT_TELEMETRY_DISABLED 1
# If using npm comment out above and use below instead
-RUN pnpm build
+# next.js has the feature of excluding build step planned https://github.com/vercel/next.js/discussions/46544
+RUN pnpm build-production
# RUN npm run build
# Production image, copy all the files and run next
@@ -51,6 +52,10 @@ USER nextjs
EXPOSE 3000
ENV PORT 3000
-ENV HOSTNAME localhost
+ENV HOSTNAME 0.0.0.0
+
+HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health \
+ || exit 1
CMD ["node", "server.js"]
diff --git a/www/app/(app)/rooms/_components/ICSSettings.tsx b/www/app/(app)/rooms/_components/ICSSettings.tsx
index 9b45ff33..58f5db98 100644
--- a/www/app/(app)/rooms/_components/ICSSettings.tsx
+++ b/www/app/(app)/rooms/_components/ICSSettings.tsx
@@ -200,7 +200,13 @@ export default function ICSSettings({
- handleForceSync(parseNonEmptyString(room.name))
+ handleForceSync(
+ parseNonEmptyString(
+ room.name,
+ true,
+ "panic! room.name is required",
+ ),
+ )
}
size="sm"
variant="ghost"
disabled={syncingRooms.has(
- parseNonEmptyString(room.name),
+ parseNonEmptyString(
+ room.name,
+ true,
+ "panic! room.name is required",
+ ),
)}
>
- {syncingRooms.has(parseNonEmptyString(room.name)) ? (
+ {syncingRooms.has(
+ parseNonEmptyString(
+ room.name,
+ true,
+ "panic! room.name is required",
+ ),
+ ) ? (
) : (
@@ -297,7 +313,13 @@ export function RoomTable({
- onCopyUrl(parseNonEmptyString(room.name))
+ onCopyUrl(
+ parseNonEmptyString(
+ room.name,
+ true,
+ "panic! room.name is required",
+ ),
+ )
}
size="sm"
variant="ghost"
diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx
index 723d698a..a7a68d2f 100644
--- a/www/app/(app)/rooms/page.tsx
+++ b/www/app/(app)/rooms/page.tsx
@@ -833,7 +833,13 @@ export default function RoomsList() {
(null);
- const roomName = parseNonEmptyString(params.roomName);
+ const roomName = parseNonEmptyString(
+ params.roomName,
+ true,
+ "panic! params.roomName is required",
+ );
const router = useRouter();
const auth = useAuth();
const status = auth.status;
@@ -308,7 +312,14 @@ export default function Room(details: RoomDetails) {
const handleMeetingSelect = (selectedMeeting: Meeting) => {
router.push(
- roomMeetingUrl(roomName, parseNonEmptyString(selectedMeeting.id)),
+ roomMeetingUrl(
+ roomName,
+ parseNonEmptyString(
+ selectedMeeting.id,
+ true,
+ "panic! selectedMeeting.id is required",
+ ),
+ ),
);
};
diff --git a/www/app/api/health/route.ts b/www/app/api/health/route.ts
new file mode 100644
index 00000000..80a58b7c
--- /dev/null
+++ b/www/app/api/health/route.ts
@@ -0,0 +1,38 @@
+import { NextResponse } from "next/server";
+
+export async function GET() {
+ const health = {
+ status: "healthy",
+ timestamp: new Date().toISOString(),
+ uptime: process.uptime(),
+ environment: process.env.NODE_ENV,
+ checks: {
+ redis: await checkRedis(),
+ },
+ };
+
+ const allHealthy = Object.values(health.checks).every((check) => check);
+
+ return NextResponse.json(health, {
+ status: allHealthy ? 200 : 503,
+ });
+}
+
+async function checkRedis(): Promise {
+ try {
+ if (!process.env.KV_URL) {
+ return false;
+ }
+
+ const { tokenCacheRedis } = await import("../../lib/redisClient");
+ const testKey = `health:check:${Date.now()}`;
+ await tokenCacheRedis.setex(testKey, 10, "OK");
+ const value = await tokenCacheRedis.get(testKey);
+ await tokenCacheRedis.del(testKey);
+
+ return value === "OK";
+ } catch (error) {
+ console.error("Redis health check failed:", error);
+ return false;
+ }
+}
diff --git a/www/app/layout.tsx b/www/app/layout.tsx
index 175b7cbc..5fc01ebe 100644
--- a/www/app/layout.tsx
+++ b/www/app/layout.tsx
@@ -6,7 +6,10 @@ import ErrorMessage from "./(errors)/errorMessage";
import { RecordingConsentProvider } from "./recordingConsentContext";
import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers";
-import { assertExistsAndNonEmptyString } from "./lib/utils";
+import { getNextEnvVar } from "./lib/nextBuild";
+import { getClientEnv } from "./lib/clientEnv";
+
+export const dynamic = "force-dynamic";
const poppins = Poppins({
subsets: ["latin"],
@@ -21,13 +24,11 @@ export const viewport: Viewport = {
maximumScale: 1,
};
-const NEXT_PUBLIC_SITE_URL = assertExistsAndNonEmptyString(
- process.env.NEXT_PUBLIC_SITE_URL,
- "NEXT_PUBLIC_SITE_URL required",
-);
+const SITE_URL = getNextEnvVar("SITE_URL");
+const env = getClientEnv();
export const metadata: Metadata = {
- metadataBase: new URL(NEXT_PUBLIC_SITE_URL),
+ metadataBase: new URL(SITE_URL),
title: {
template: "%s – Reflector",
default: "Reflector - AI-Powered Meeting Transcriptions by Monadical",
@@ -74,15 +75,16 @@ export default async function RootLayout({
}) {
return (
-
-
- "something went really wrong"}>
-
-
- {children}
-
-
-
+
+ "something went really wrong"}>
+
+
+ {children}
+
+
);
diff --git a/www/app/lib/apiClient.tsx b/www/app/lib/apiClient.tsx
index a5cec06b..442d2f42 100644
--- a/www/app/lib/apiClient.tsx
+++ b/www/app/lib/apiClient.tsx
@@ -3,21 +3,19 @@
import createClient from "openapi-fetch";
import type { paths } from "../reflector-api";
import createFetchClient from "openapi-react-query";
-import { assertExistsAndNonEmptyString, parseNonEmptyString } from "./utils";
+import { parseNonEmptyString } from "./utils";
import { isBuildPhase } from "./next";
import { getSession } from "next-auth/react";
import { assertExtendedToken } from "./types";
+import { getClientEnv } from "./clientEnv";
export const API_URL = !isBuildPhase
- ? assertExistsAndNonEmptyString(
- process.env.NEXT_PUBLIC_API_URL,
- "NEXT_PUBLIC_API_URL required",
- )
+ ? getClientEnv().API_URL
: "http://localhost";
-// TODO decide strict validation or not
-export const WEBSOCKET_URL =
- process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
+export const WEBSOCKET_URL = !isBuildPhase
+ ? getClientEnv().WEBSOCKET_URL || "ws://127.0.0.1:1250"
+ : "ws://localhost";
export const client = createClient({
baseUrl: API_URL,
@@ -44,7 +42,7 @@ client.use({
if (token !== null) {
request.headers.set(
"Authorization",
- `Bearer ${parseNonEmptyString(token)}`,
+ `Bearer ${parseNonEmptyString(token, true, "panic! token is required")}`,
);
}
// XXX Only set Content-Type if not already set (FormData will set its own boundary)
diff --git a/www/app/lib/authBackend.ts b/www/app/lib/authBackend.ts
index 5e9767c9..a44f1d36 100644
--- a/www/app/lib/authBackend.ts
+++ b/www/app/lib/authBackend.ts
@@ -18,26 +18,25 @@ import {
deleteTokenCache,
} from "./redisTokenCache";
import { tokenCacheRedis, redlock } from "./redisClient";
-import { isBuildPhase } from "./next";
import { sequenceThrows } from "./errorUtils";
import { featureEnabled } from "./features";
+import { getNextEnvVar } from "./nextBuild";
const TOKEN_CACHE_TTL = REFRESH_ACCESS_TOKEN_BEFORE;
-const getAuthentikClientId = () =>
- assertExistsAndNonEmptyString(
- process.env.AUTHENTIK_CLIENT_ID,
- "AUTHENTIK_CLIENT_ID required",
- );
-const getAuthentikClientSecret = () =>
- assertExistsAndNonEmptyString(
- process.env.AUTHENTIK_CLIENT_SECRET,
- "AUTHENTIK_CLIENT_SECRET required",
- );
+const getAuthentikClientId = () => getNextEnvVar("AUTHENTIK_CLIENT_ID");
+const getAuthentikClientSecret = () => getNextEnvVar("AUTHENTIK_CLIENT_SECRET");
const getAuthentikRefreshTokenUrl = () =>
- assertExistsAndNonEmptyString(
- process.env.AUTHENTIK_REFRESH_TOKEN_URL,
- "AUTHENTIK_REFRESH_TOKEN_URL required",
- );
+ getNextEnvVar("AUTHENTIK_REFRESH_TOKEN_URL");
+
+const getAuthentikIssuer = () => {
+ const stringUrl = getNextEnvVar("AUTHENTIK_ISSUER");
+ try {
+ new URL(stringUrl);
+ } catch (e) {
+ throw new Error("AUTHENTIK_ISSUER is not a valid URL: " + stringUrl);
+ }
+ return stringUrl;
+};
export const authOptions = (): AuthOptions =>
featureEnabled("requireLogin")
@@ -45,16 +44,17 @@ export const authOptions = (): AuthOptions =>
providers: [
AuthentikProvider({
...(() => {
- const [clientId, clientSecret] = sequenceThrows(
+ const [clientId, clientSecret, issuer] = sequenceThrows(
getAuthentikClientId,
getAuthentikClientSecret,
+ getAuthentikIssuer,
);
return {
clientId,
clientSecret,
+ issuer,
};
})(),
- issuer: process.env.AUTHENTIK_ISSUER,
authorization: {
params: {
scope: "openid email profile offline_access",
diff --git a/www/app/lib/clientEnv.ts b/www/app/lib/clientEnv.ts
new file mode 100644
index 00000000..04797ce2
--- /dev/null
+++ b/www/app/lib/clientEnv.ts
@@ -0,0 +1,88 @@
+import {
+ assertExists,
+ assertExistsAndNonEmptyString,
+ NonEmptyString,
+ parseNonEmptyString,
+} from "./utils";
+import { isBuildPhase } from "./next";
+import { getNextEnvVar } from "./nextBuild";
+
+export const FEATURE_REQUIRE_LOGIN_ENV_NAME = "FEATURE_REQUIRE_LOGIN" as const;
+export const FEATURE_PRIVACY_ENV_NAME = "FEATURE_PRIVACY" as const;
+export const FEATURE_BROWSE_ENV_NAME = "FEATURE_BROWSE" as const;
+export const FEATURE_SEND_TO_ZULIP_ENV_NAME = "FEATURE_SEND_TO_ZULIP" as const;
+export const FEATURE_ROOMS_ENV_NAME = "FEATURE_ROOMS" as const;
+
+const FEATURE_ENV_NAMES = [
+ FEATURE_REQUIRE_LOGIN_ENV_NAME,
+ FEATURE_PRIVACY_ENV_NAME,
+ FEATURE_BROWSE_ENV_NAME,
+ FEATURE_SEND_TO_ZULIP_ENV_NAME,
+ FEATURE_ROOMS_ENV_NAME,
+] as const;
+
+export type EnvFeaturePartial = {
+ [key in (typeof FEATURE_ENV_NAMES)[number]]: boolean;
+};
+
+// CONTRACT: isomorphic with JSON.stringify
+export type ClientEnvCommon = EnvFeaturePartial & {
+ API_URL: NonEmptyString;
+ WEBSOCKET_URL: NonEmptyString | null;
+};
+
+let clientEnv: ClientEnvCommon | null = null;
+export const getClientEnvClient = (): ClientEnvCommon => {
+ if (typeof window === "undefined") {
+ throw new Error(
+ "getClientEnv() called during SSR - this should only be called in browser environment",
+ );
+ }
+ if (clientEnv) return clientEnv;
+ clientEnv = assertExists(
+ JSON.parse(
+ assertExistsAndNonEmptyString(
+ document.body.dataset.env,
+ "document.body.dataset.env is missing",
+ ),
+ ),
+ "document.body.dataset.env is parsed to nullish",
+ );
+ return clientEnv!;
+};
+
+const parseBooleanString = (str: string | undefined): boolean => {
+ return str === "true";
+};
+
+export const getClientEnvServer = (): ClientEnvCommon => {
+ if (typeof window !== "undefined") {
+ throw new Error(
+ "getClientEnv() not called during SSR - this should only be called in server environment",
+ );
+ }
+ if (clientEnv) return clientEnv;
+
+ const features = FEATURE_ENV_NAMES.reduce((acc, x) => {
+ acc[x] = parseBooleanString(process.env[x]);
+ return acc;
+ }, {} as EnvFeaturePartial);
+
+ if (isBuildPhase) {
+ return {
+ API_URL: getNextEnvVar("API_URL"),
+ WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"),
+ ...features,
+ };
+ }
+
+ clientEnv = {
+ API_URL: getNextEnvVar("API_URL"),
+ WEBSOCKET_URL: getNextEnvVar("WEBSOCKET_URL"),
+ ...features,
+ };
+ return clientEnv;
+};
+
+export const getClientEnv =
+ typeof window === "undefined" ? getClientEnvServer : getClientEnvClient;
diff --git a/www/app/lib/features.ts b/www/app/lib/features.ts
index 7684c8e0..a96e23ef 100644
--- a/www/app/lib/features.ts
+++ b/www/app/lib/features.ts
@@ -1,3 +1,11 @@
+import {
+ FEATURE_BROWSE_ENV_NAME,
+ FEATURE_PRIVACY_ENV_NAME,
+ FEATURE_REQUIRE_LOGIN_ENV_NAME,
+ FEATURE_ROOMS_ENV_NAME,
+ FEATURE_SEND_TO_ZULIP_ENV_NAME,
+} from "./clientEnv";
+
export const FEATURES = [
"requireLogin",
"privacy",
@@ -26,26 +34,25 @@ function parseBooleanEnv(
return value.toLowerCase() === "true";
}
-// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
const features: Features = {
requireLogin: parseBooleanEnv(
- process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
+ process.env[FEATURE_REQUIRE_LOGIN_ENV_NAME],
DEFAULT_FEATURES.requireLogin,
),
privacy: parseBooleanEnv(
- process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
+ process.env[FEATURE_PRIVACY_ENV_NAME],
DEFAULT_FEATURES.privacy,
),
browse: parseBooleanEnv(
- process.env.NEXT_PUBLIC_FEATURE_BROWSE,
+ process.env[FEATURE_BROWSE_ENV_NAME],
DEFAULT_FEATURES.browse,
),
sendToZulip: parseBooleanEnv(
- process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
+ process.env[FEATURE_SEND_TO_ZULIP_ENV_NAME],
DEFAULT_FEATURES.sendToZulip,
),
rooms: parseBooleanEnv(
- process.env.NEXT_PUBLIC_FEATURE_ROOMS,
+ process.env[FEATURE_ROOMS_ENV_NAME],
DEFAULT_FEATURES.rooms,
),
};
diff --git a/www/app/lib/nextBuild.ts b/www/app/lib/nextBuild.ts
new file mode 100644
index 00000000..b2e13797
--- /dev/null
+++ b/www/app/lib/nextBuild.ts
@@ -0,0 +1,17 @@
+import { isBuildPhase } from "./next";
+import { assertExistsAndNonEmptyString, NonEmptyString } from "./utils";
+
+const _getNextEnvVar = (name: string, e?: string): NonEmptyString =>
+ isBuildPhase
+ ? (() => {
+ throw new Error(
+ "panic! getNextEnvVar called during build phase; we don't support build envs",
+ );
+ })()
+ : assertExistsAndNonEmptyString(
+ process.env[name],
+ `${name} is required; ${e}`,
+ );
+
+export const getNextEnvVar = (name: string, e?: string): NonEmptyString =>
+ _getNextEnvVar(name, e);
diff --git a/www/app/lib/utils.ts b/www/app/lib/utils.ts
index 11939cdb..e9260a9b 100644
--- a/www/app/lib/utils.ts
+++ b/www/app/lib/utils.ts
@@ -1,7 +1,3 @@
-export function isDevelopment() {
- return process.env.NEXT_PUBLIC_ENV === "development";
-}
-
// Function to calculate WCAG contrast ratio
export const getContrastRatio = (
foreground: [number, number, number],
@@ -145,8 +141,15 @@ export const parseMaybeNonEmptyString = (
s = trim ? s.trim() : s;
return s.length > 0 ? (s as NonEmptyString) : null;
};
-export const parseNonEmptyString = (s: string, trim = true): NonEmptyString =>
- assertExists(parseMaybeNonEmptyString(s, trim), "Expected non-empty string");
+export const parseNonEmptyString = (
+ s: string,
+ trim = true,
+ e?: string,
+): NonEmptyString =>
+ assertExists(
+ parseMaybeNonEmptyString(s, trim),
+ "Expected non-empty string" + (e ? `: ${e}` : ""),
+ );
export const assertExists = (
value: T | null | undefined,
@@ -173,4 +176,8 @@ export const assertExistsAndNonEmptyString = (
value: string | null | undefined,
err?: string,
): NonEmptyString =>
- parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));
+ parseNonEmptyString(
+ assertExists(value, err || "Expected non-empty string"),
+ true,
+ err,
+ );
diff --git a/www/app/providers.tsx b/www/app/providers.tsx
index 020112ac..37b37a0e 100644
--- a/www/app/providers.tsx
+++ b/www/app/providers.tsx
@@ -10,6 +10,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "./lib/queryClient";
import { AuthProvider } from "./lib/AuthProvider";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
+import { RecordingConsentProvider } from "./recordingConsentContext";
const WherebyProvider = dynamic(
() =>
@@ -26,10 +27,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
-
- {children}
-
-
+
+
+ {children}
+
+
+
diff --git a/www/package.json b/www/package.json
index c93a9554..5169dbe2 100644
--- a/www/package.json
+++ b/www/package.json
@@ -5,6 +5,7 @@
"scripts": {
"dev": "next dev",
"build": "next build",
+ "build-production": "next build --experimental-build-mode compile",
"start": "next start",
"lint": "next lint",
"format": "prettier --write .",
From 36608849ec64e953e3be456172502762e3c33df9 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Wed, 24 Sep 2025 11:57:49 -0400
Subject: [PATCH 49/77] fix: restore feature boolean logic (#671)
Co-authored-by: Igor Loskutov
---
www/app/lib/clientEnv.ts | 7 ++++--
www/app/lib/features.ts | 53 ++++++++++++++++++----------------------
2 files changed, 29 insertions(+), 31 deletions(-)
diff --git a/www/app/lib/clientEnv.ts b/www/app/lib/clientEnv.ts
index 04797ce2..2c4a01c8 100644
--- a/www/app/lib/clientEnv.ts
+++ b/www/app/lib/clientEnv.ts
@@ -21,8 +21,10 @@ const FEATURE_ENV_NAMES = [
FEATURE_ROOMS_ENV_NAME,
] as const;
+export type FeatureEnvName = (typeof FEATURE_ENV_NAMES)[number];
+
export type EnvFeaturePartial = {
- [key in (typeof FEATURE_ENV_NAMES)[number]]: boolean;
+ [key in FeatureEnvName]: boolean | null;
};
// CONTRACT: isomorphic with JSON.stringify
@@ -51,7 +53,8 @@ export const getClientEnvClient = (): ClientEnvCommon => {
return clientEnv!;
};
-const parseBooleanString = (str: string | undefined): boolean => {
+const parseBooleanString = (str: string | undefined): boolean | null => {
+ if (str === undefined) return null;
return str === "true";
};
diff --git a/www/app/lib/features.ts b/www/app/lib/features.ts
index a96e23ef..eebfc816 100644
--- a/www/app/lib/features.ts
+++ b/www/app/lib/features.ts
@@ -4,6 +4,8 @@ import {
FEATURE_REQUIRE_LOGIN_ENV_NAME,
FEATURE_ROOMS_ENV_NAME,
FEATURE_SEND_TO_ZULIP_ENV_NAME,
+ FeatureEnvName,
+ getClientEnv,
} from "./clientEnv";
export const FEATURES = [
@@ -26,37 +28,30 @@ export const DEFAULT_FEATURES: Features = {
rooms: true,
} as const;
-function parseBooleanEnv(
- value: string | undefined,
- defaultValue: boolean = false,
-): boolean {
- if (!value) return defaultValue;
- return value.toLowerCase() === "true";
-}
+export const ENV_TO_FEATURE: {
+ [k in FeatureEnvName]: FeatureName;
+} = {
+ FEATURE_REQUIRE_LOGIN: "requireLogin",
+ FEATURE_PRIVACY: "privacy",
+ FEATURE_BROWSE: "browse",
+ FEATURE_SEND_TO_ZULIP: "sendToZulip",
+ FEATURE_ROOMS: "rooms",
+} as const;
-const features: Features = {
- requireLogin: parseBooleanEnv(
- process.env[FEATURE_REQUIRE_LOGIN_ENV_NAME],
- DEFAULT_FEATURES.requireLogin,
- ),
- privacy: parseBooleanEnv(
- process.env[FEATURE_PRIVACY_ENV_NAME],
- DEFAULT_FEATURES.privacy,
- ),
- browse: parseBooleanEnv(
- process.env[FEATURE_BROWSE_ENV_NAME],
- DEFAULT_FEATURES.browse,
- ),
- sendToZulip: parseBooleanEnv(
- process.env[FEATURE_SEND_TO_ZULIP_ENV_NAME],
- DEFAULT_FEATURES.sendToZulip,
- ),
- rooms: parseBooleanEnv(
- process.env[FEATURE_ROOMS_ENV_NAME],
- DEFAULT_FEATURES.rooms,
- ),
+export const FEATURE_TO_ENV: {
+ [k in FeatureName]: FeatureEnvName;
+} = {
+ requireLogin: "FEATURE_REQUIRE_LOGIN",
+ privacy: "FEATURE_PRIVACY",
+ browse: "FEATURE_BROWSE",
+ sendToZulip: "FEATURE_SEND_TO_ZULIP",
+ rooms: "FEATURE_ROOMS",
};
+const features = getClientEnv();
+
export const featureEnabled = (featureName: FeatureName): boolean => {
- return features[featureName];
+ const isSet = features[FEATURE_TO_ENV[featureName]];
+ if (isSet === null) return DEFAULT_FEATURES[featureName];
+ return isSet;
};
From 969bd84fcc14851d1a101412a0ba115f1b7cde82 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Wed, 24 Sep 2025 12:27:45 -0400
Subject: [PATCH 50/77] feat: container build for www / github (#672)
Co-authored-by: Igor Loskutov
---
.github/workflows/docker-frontend.yml | 57 +++++++++++++++++++++++++++
1 file changed, 57 insertions(+)
create mode 100644 .github/workflows/docker-frontend.yml
diff --git a/.github/workflows/docker-frontend.yml b/.github/workflows/docker-frontend.yml
new file mode 100644
index 00000000..ea861782
--- /dev/null
+++ b/.github/workflows/docker-frontend.yml
@@ -0,0 +1,57 @@
+name: Build and Push Frontend Docker Image
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'www/**'
+ - '.github/workflows/docker-frontend.yml'
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}-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
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_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: ./www
+ file: ./www/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
\ No newline at end of file
From 5d98754305c6c540dd194dda268544f6d88bfaf8 Mon Sep 17 00:00:00 2001
From: Sergey Mankovsky
Date: Mon, 29 Sep 2025 23:07:49 +0200
Subject: [PATCH 51/77] fix: security review (#656)
* Add security review doc
* Add tests to reproduce security issues
* Fix security issues
* Fix tests
* Set auth auth backend for tests
* Fix ics api tests
* Fix transcript mutate check
* Update frontent env var names
* Remove permissions doc
---
server/pyproject.toml | 1 +
server/reflector/db/transcripts.py | 13 +
server/reflector/views/rooms.py | 26 +-
server/reflector/views/transcripts.py | 27 +-
.../views/transcripts_participants.py | 18 +-
server/reflector/views/transcripts_speaker.py | 12 +-
.../reflector/views/transcripts_websocket.py | 13 +-
server/tests/conftest.py | 157 +++++++
server/tests/test_room_ics_api.py | 13 +-
server/tests/test_security_permissions.py | 384 ++++++++++++++++++
server/tests/test_transcripts.py | 64 +--
.../tests/test_transcripts_audio_download.py | 4 +-
server/tests/test_transcripts_participants.py | 8 +-
server/tests/test_transcripts_speaker.py | 14 +-
www/app/(app)/transcripts/useWebSockets.ts | 4 +-
15 files changed, 647 insertions(+), 111 deletions(-)
create mode 100644 server/tests/test_security_permissions.py
diff --git a/server/pyproject.toml b/server/pyproject.toml
index f63947c8..ffa28d15 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -112,6 +112,7 @@ source = ["reflector"]
[tool.pytest_env]
ENVIRONMENT = "pytest"
DATABASE_URL = "postgresql://test_user:test_password@localhost:15432/reflector_test"
+AUTH_BACKEND = "jwt"
[tool.pytest.ini_options]
addopts = "-ra -q --disable-pytest-warnings --cov --cov-report html -v"
diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py
index 47148995..b82e4fe1 100644
--- a/server/reflector/db/transcripts.py
+++ b/server/reflector/db/transcripts.py
@@ -647,6 +647,19 @@ class TranscriptController:
query = transcripts.delete().where(transcripts.c.recording_id == recording_id)
await get_database().execute(query)
+ @staticmethod
+ def user_can_mutate(transcript: Transcript, user_id: str | None) -> bool:
+ """
+ Returns True if the given user is allowed to modify the transcript.
+
+ Policy:
+ - Anonymous transcripts (user_id is None) cannot be modified via API
+ - Only the owner (matching user_id) can modify their transcript
+ """
+ if transcript.user_id is None:
+ return False
+ return user_id and transcript.user_id == user_id
+
@asynccontextmanager
async def transaction(self):
"""
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index b849ae3d..70e3f9e4 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -199,6 +199,8 @@ async def rooms_get(
room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
if not room:
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")
return room
@@ -229,9 +231,9 @@ async def rooms_get_by_name(
@router.post("/rooms", response_model=Room)
async def rooms_create(
room: CreateRoom,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
return await rooms_controller.add(
name=room.name,
@@ -256,12 +258,14 @@ async def rooms_create(
async def rooms_update(
room_id: str,
info: UpdateRoom,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
room = await rooms_controller.get_by_id_for_http(room_id, user_id=user_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
+ if room.user_id != user_id:
+ raise HTTPException(status_code=403, detail="Not authorized")
values = info.dict(exclude_unset=True)
await rooms_controller.update(room, values)
return room
@@ -270,12 +274,14 @@ async def rooms_update(
@router.delete("/rooms/{room_id}", response_model=DeletionStatus)
async def rooms_delete(
room_id: str,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
- user_id = user["sub"] if user else None
- room = await rooms_controller.get_by_id(room_id, user_id=user_id)
+ user_id = user["sub"]
+ room = await rooms_controller.get_by_id(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
+ if room.user_id != user_id:
+ raise HTTPException(status_code=403, detail="Not authorized")
await rooms_controller.remove_by_id(room.id, user_id=user_id)
return DeletionStatus(status="ok")
@@ -339,16 +345,16 @@ async def rooms_create_meeting(
@router.post("/rooms/{room_id}/webhook/test", response_model=WebhookTestResult)
async def rooms_test_webhook(
room_id: str,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
"""Test webhook configuration by sending a sample payload."""
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
room = await rooms_controller.get_by_id(room_id)
if not room:
raise HTTPException(status_code=404, detail="Room not found")
- if user_id and room.user_id != user_id:
+ if room.user_id != user_id:
raise HTTPException(
status_code=403, detail="Not authorized to test this room's webhook"
)
diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py
index ed2445ae..839c6cdb 100644
--- a/server/reflector/views/transcripts.py
+++ b/server/reflector/views/transcripts.py
@@ -9,8 +9,6 @@ from pydantic import BaseModel, Field, constr, field_serializer
import reflector.auth as auth
from reflector.db import get_database
-from reflector.db.meetings import meetings_controller
-from reflector.db.rooms import rooms_controller
from reflector.db.search import (
DEFAULT_SEARCH_LIMIT,
SearchLimit,
@@ -344,12 +342,14 @@ async def transcript_get(
async def transcript_update(
transcript_id: str,
info: UpdateTranscript,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
+ if not transcripts_controller.user_can_mutate(transcript, user_id):
+ raise HTTPException(status_code=403, detail="Not authorized")
values = info.dict(exclude_unset=True)
updated_transcript = await transcripts_controller.update(transcript, values)
return updated_transcript
@@ -358,18 +358,14 @@ async def transcript_update(
@router.delete("/transcripts/{transcript_id}", response_model=DeletionStatus)
async def transcript_delete(
transcript_id: str,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id(transcript_id)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
-
- if transcript.meeting_id:
- meeting = await meetings_controller.get_by_id(transcript.meeting_id)
- room = await rooms_controller.get_by_id(meeting.room_id)
- if room.is_shared:
- user_id = None
+ if not transcripts_controller.user_can_mutate(transcript, user_id):
+ raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.remove_by_id(transcript.id, user_id=user_id)
return DeletionStatus(status="ok")
@@ -443,15 +439,16 @@ async def transcript_post_to_zulip(
stream: str,
topic: str,
include_topics: bool,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
):
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
-
+ if not transcripts_controller.user_can_mutate(transcript, user_id):
+ raise HTTPException(status_code=403, detail="Not authorized")
content = get_zulip_message(transcript, include_topics)
message_updated = False
diff --git a/server/reflector/views/transcripts_participants.py b/server/reflector/views/transcripts_participants.py
index 6b407c69..eb314eff 100644
--- a/server/reflector/views/transcripts_participants.py
+++ b/server/reflector/views/transcripts_participants.py
@@ -56,12 +56,14 @@ async def transcript_get_participants(
async def transcript_add_participant(
transcript_id: str,
participant: CreateParticipant,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> Participant:
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
+ if transcript.user_id is not None and transcript.user_id != user_id:
+ raise HTTPException(status_code=403, detail="Not authorized")
# ensure the speaker is unique
if participant.speaker is not None and transcript.participants is not None:
@@ -101,12 +103,14 @@ async def transcript_update_participant(
transcript_id: str,
participant_id: str,
participant: UpdateParticipant,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> Participant:
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
+ if transcript.user_id is not None and transcript.user_id != user_id:
+ raise HTTPException(status_code=403, detail="Not authorized")
# ensure the speaker is unique
for p in transcript.participants:
@@ -138,11 +142,13 @@ async def transcript_update_participant(
async def transcript_delete_participant(
transcript_id: str,
participant_id: str,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> DeletionStatus:
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
+ if transcript.user_id is not None and transcript.user_id != user_id:
+ raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.delete_participant(transcript, participant_id)
return DeletionStatus(status="ok")
diff --git a/server/reflector/views/transcripts_speaker.py b/server/reflector/views/transcripts_speaker.py
index e027bd44..787e554a 100644
--- a/server/reflector/views/transcripts_speaker.py
+++ b/server/reflector/views/transcripts_speaker.py
@@ -35,12 +35,14 @@ class SpeakerMerge(BaseModel):
async def transcript_assign_speaker(
transcript_id: str,
assignment: SpeakerAssignment,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> SpeakerAssignmentStatus:
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
+ if transcript.user_id is not None and transcript.user_id != user_id:
+ raise HTTPException(status_code=403, detail="Not authorized")
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
@@ -113,12 +115,14 @@ async def transcript_assign_speaker(
async def transcript_merge_speaker(
transcript_id: str,
merge: SpeakerMerge,
- user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
) -> SpeakerAssignmentStatus:
- user_id = user["sub"] if user else None
+ user_id = user["sub"]
transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id
)
+ if transcript.user_id is not None and transcript.user_id != user_id:
+ raise HTTPException(status_code=403, detail="Not authorized")
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
diff --git a/server/reflector/views/transcripts_websocket.py b/server/reflector/views/transcripts_websocket.py
index c78e418c..ccb7d7ff 100644
--- a/server/reflector/views/transcripts_websocket.py
+++ b/server/reflector/views/transcripts_websocket.py
@@ -4,8 +4,11 @@ Transcripts websocket API
"""
-from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
+from typing import Optional
+from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
+
+import reflector.auth as auth
from reflector.db.transcripts import transcripts_controller
from reflector.ws_manager import get_ws_manager
@@ -21,10 +24,12 @@ async def transcript_get_websocket_events(transcript_id: str):
async def transcript_events_websocket(
transcript_id: str,
websocket: WebSocket,
- # user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
+ user: Optional[auth.UserInfo] = Depends(auth.current_user_optional),
):
- # user_id = user["sub"] if user else None
- transcript = await transcripts_controller.get_by_id(transcript_id)
+ user_id = user["sub"] if user else None
+ transcript = await transcripts_controller.get_by_id_for_http(
+ transcript_id, user_id=user_id
+ )
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
diff --git a/server/tests/conftest.py b/server/tests/conftest.py
index 22fe4193..8271d1ad 100644
--- a/server/tests/conftest.py
+++ b/server/tests/conftest.py
@@ -1,4 +1,5 @@
import os
+from contextlib import asynccontextmanager
from tempfile import NamedTemporaryFile
from unittest.mock import patch
@@ -337,6 +338,162 @@ async def client():
yield ac
+@pytest.fixture(autouse=True)
+async def ws_manager_in_memory(monkeypatch):
+ """Replace Redis-based WS manager with an in-memory implementation for tests."""
+ import asyncio
+ import json
+
+ from reflector.ws_manager import WebsocketManager
+
+ class _InMemorySubscriber:
+ def __init__(self, queue: asyncio.Queue):
+ self.queue = queue
+
+ async def get_message(self, ignore_subscribe_messages: bool = True):
+ try:
+ return await asyncio.wait_for(self.queue.get(), timeout=0.05)
+ except Exception:
+ return None
+
+ class InMemoryPubSubManager:
+ def __init__(self):
+ self.queues: dict[str, asyncio.Queue] = {}
+ self.connected = False
+
+ async def connect(self) -> None:
+ self.connected = True
+
+ async def disconnect(self) -> None:
+ self.connected = False
+
+ async def send_json(self, room_id: str, message: dict) -> None:
+ if room_id not in self.queues:
+ self.queues[room_id] = asyncio.Queue()
+ payload = json.dumps(message).encode("utf-8")
+ await self.queues[room_id].put(
+ {"channel": room_id.encode("utf-8"), "data": payload}
+ )
+
+ async def subscribe(self, room_id: str):
+ if room_id not in self.queues:
+ self.queues[room_id] = asyncio.Queue()
+ return _InMemorySubscriber(self.queues[room_id])
+
+ async def unsubscribe(self, room_id: str) -> None:
+ # keep queue for potential later resubscribe within same test
+ pass
+
+ pubsub = InMemoryPubSubManager()
+ ws_manager = WebsocketManager(pubsub_client=pubsub)
+
+ def _get_ws_manager():
+ return ws_manager
+
+ # Patch all places that imported get_ws_manager at import time
+ monkeypatch.setattr("reflector.ws_manager.get_ws_manager", _get_ws_manager)
+ monkeypatch.setattr(
+ "reflector.pipelines.main_live_pipeline.get_ws_manager", _get_ws_manager
+ )
+ monkeypatch.setattr(
+ "reflector.views.transcripts_websocket.get_ws_manager", _get_ws_manager
+ )
+
+ # Websocket auth: avoid OAuth2 on websocket dependencies; allow anonymous
+ import reflector.auth as auth
+
+ # Ensure FastAPI uses our override for routes that captured the original callable
+ from reflector.app import app as fastapi_app
+
+ try:
+ fastapi_app.dependency_overrides[auth.current_user_optional] = lambda: None
+ except Exception:
+ pass
+
+ # Stub Redis cache used by profanity filter to avoid external Redis
+ from reflector import redis_cache as rc
+
+ class _FakeRedis:
+ def __init__(self):
+ self._data = {}
+
+ def get(self, key):
+ value = self._data.get(key)
+ if value is None:
+ return None
+ if isinstance(value, bytes):
+ return value
+ return str(value).encode("utf-8")
+
+ def setex(self, key, duration, value):
+ # ignore duration for tests
+ if isinstance(value, bytes):
+ self._data[key] = value
+ else:
+ self._data[key] = str(value).encode("utf-8")
+
+ fake_redises: dict[int, _FakeRedis] = {}
+
+ def _get_redis_client(db=0):
+ if db not in fake_redises:
+ fake_redises[db] = _FakeRedis()
+ return fake_redises[db]
+
+ monkeypatch.setattr(rc, "get_redis_client", _get_redis_client)
+
+ yield
+
+
+@pytest.fixture
+@pytest.mark.asyncio
+async def authenticated_client():
+ async with authenticated_client_ctx():
+ yield
+
+
+@pytest.fixture
+@pytest.mark.asyncio
+async def authenticated_client2():
+ async with authenticated_client2_ctx():
+ yield
+
+
+@asynccontextmanager
+async def authenticated_client_ctx():
+ from reflector.app import app
+ from reflector.auth import current_user, current_user_optional
+
+ app.dependency_overrides[current_user] = lambda: {
+ "sub": "randomuserid",
+ "email": "test@mail.com",
+ }
+ app.dependency_overrides[current_user_optional] = lambda: {
+ "sub": "randomuserid",
+ "email": "test@mail.com",
+ }
+ yield
+ del app.dependency_overrides[current_user]
+ del app.dependency_overrides[current_user_optional]
+
+
+@asynccontextmanager
+async def authenticated_client2_ctx():
+ from reflector.app import app
+ from reflector.auth import current_user, current_user_optional
+
+ app.dependency_overrides[current_user] = lambda: {
+ "sub": "randomuserid2",
+ "email": "test@mail.com",
+ }
+ app.dependency_overrides[current_user_optional] = lambda: {
+ "sub": "randomuserid2",
+ "email": "test@mail.com",
+ }
+ yield
+ del app.dependency_overrides[current_user]
+ del app.dependency_overrides[current_user_optional]
+
+
@pytest.fixture(scope="session")
def fake_mp3_upload():
with patch(
diff --git a/server/tests/test_room_ics_api.py b/server/tests/test_room_ics_api.py
index 27a784d7..8e7cf76f 100644
--- a/server/tests/test_room_ics_api.py
+++ b/server/tests/test_room_ics_api.py
@@ -11,14 +11,21 @@ from reflector.db.rooms import rooms_controller
@pytest.fixture
async def authenticated_client(client):
from reflector.app import app
- from reflector.auth import current_user_optional
+ from reflector.auth import current_user, current_user_optional
+ app.dependency_overrides[current_user] = lambda: {
+ "sub": "test-user",
+ "email": "test@example.com",
+ }
app.dependency_overrides[current_user_optional] = lambda: {
"sub": "test-user",
"email": "test@example.com",
}
- yield client
- del app.dependency_overrides[current_user_optional]
+ try:
+ yield client
+ finally:
+ del app.dependency_overrides[current_user]
+ del app.dependency_overrides[current_user_optional]
@pytest.mark.asyncio
diff --git a/server/tests/test_security_permissions.py b/server/tests/test_security_permissions.py
new file mode 100644
index 00000000..ef871152
--- /dev/null
+++ b/server/tests/test_security_permissions.py
@@ -0,0 +1,384 @@
+import asyncio
+import shutil
+import threading
+import time
+from pathlib import Path
+
+import pytest
+from httpx_ws import aconnect_ws
+from uvicorn import Config, Server
+
+from reflector import zulip as zulip_module
+from reflector.app import app
+from reflector.db import get_database
+from reflector.db.meetings import meetings_controller
+from reflector.db.rooms import Room, rooms_controller
+from reflector.db.transcripts import (
+ SourceKind,
+ TranscriptTopic,
+ transcripts_controller,
+)
+from reflector.processors.types import Word
+from reflector.settings import settings
+from reflector.views.transcripts import create_access_token
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_delete_transcript_in_shared_room(client):
+ # Create a shared room with a fake owner id so meeting has a room_id
+ room = await rooms_controller.add(
+ name="shared-room-test",
+ user_id="owner-1",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=True,
+ webhook_url="",
+ webhook_secret="",
+ )
+
+ # Create a meeting for that room (so transcript.meeting_id links to the shared room)
+ meeting = await meetings_controller.create(
+ id="meeting-sec-test",
+ room_name="room-sec-test",
+ room_url="room-url",
+ host_room_url="host-url",
+ start_date=Room.model_fields["created_at"].default_factory(),
+ end_date=Room.model_fields["created_at"].default_factory(),
+ room=room,
+ )
+
+ # Create a transcript owned by someone else and link it to meeting
+ t = await transcripts_controller.add(
+ name="to-delete",
+ source_kind=SourceKind.LIVE,
+ user_id="owner-2",
+ meeting_id=meeting.id,
+ room_id=room.id,
+ share_mode="private",
+ )
+
+ # Anonymous DELETE should be rejected
+ del_resp = await client.delete(f"/transcripts/{t.id}")
+ assert del_resp.status_code == 401, del_resp.text
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_mutate_participants_on_public_transcript(client):
+ # Create a public transcript with no owner
+ t = await transcripts_controller.add(
+ name="public-transcript",
+ source_kind=SourceKind.LIVE,
+ user_id=None,
+ share_mode="public",
+ )
+
+ # Anonymous POST participant must be rejected
+ resp = await client.post(
+ f"/transcripts/{t.id}/participants",
+ json={"name": "AnonUser", "speaker": 0},
+ )
+ assert resp.status_code == 401, resp.text
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_update_and_delete_room(client):
+ # Create room as owner id "owner-3" via controller
+ room = await rooms_controller.add(
+ name="room-anon-update-delete",
+ user_id="owner-3",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ webhook_url="",
+ webhook_secret="",
+ )
+
+ # Anonymous PATCH via API (no auth)
+ resp = await client.patch(
+ f"/rooms/{room.id}",
+ json={
+ "name": "room-anon-updated",
+ "zulip_auto_post": False,
+ "zulip_stream": "",
+ "zulip_topic": "",
+ "is_locked": False,
+ "room_mode": "normal",
+ "recording_type": "cloud",
+ "recording_trigger": "automatic-2nd-participant",
+ "is_shared": False,
+ "webhook_url": "",
+ "webhook_secret": "",
+ },
+ )
+ # Expect authentication required
+ assert resp.status_code == 401, resp.text
+
+ # Anonymous DELETE via API
+ del_resp = await client.delete(f"/rooms/{room.id}")
+ assert del_resp.status_code == 401, del_resp.text
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_post_transcript_to_zulip(client, monkeypatch):
+ # Create a public transcript with some content
+ t = await transcripts_controller.add(
+ name="zulip-public",
+ source_kind=SourceKind.LIVE,
+ user_id=None,
+ share_mode="public",
+ )
+
+ # Mock send/update calls
+ def _fake_send_message_to_zulip(stream, topic, content):
+ return {"id": 12345}
+
+ async def _fake_update_message(message_id, stream, topic, content):
+ return {"result": "success"}
+
+ monkeypatch.setattr(
+ zulip_module, "send_message_to_zulip", _fake_send_message_to_zulip
+ )
+ monkeypatch.setattr(zulip_module, "update_zulip_message", _fake_update_message)
+
+ # Anonymous POST to Zulip endpoint
+ resp = await client.post(
+ f"/transcripts/{t.id}/zulip",
+ params={"stream": "general", "topic": "Updates", "include_topics": False},
+ )
+ assert resp.status_code == 401, resp.text
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_assign_speaker_on_public_transcript(client):
+ # Create public transcript
+ t = await transcripts_controller.add(
+ name="public-assign",
+ source_kind=SourceKind.LIVE,
+ user_id=None,
+ share_mode="public",
+ )
+
+ # Add a topic with words to be reassigned
+ topic = TranscriptTopic(
+ title="T1",
+ summary="S1",
+ timestamp=0.0,
+ transcript="Hello",
+ words=[Word(start=0.0, end=1.0, text="Hello", speaker=0)],
+ )
+ transcript = await transcripts_controller.get_by_id(t.id)
+ await transcripts_controller.upsert_topic(transcript, topic)
+
+ # Anonymous assign speaker over time range covering the word
+ resp = await client.patch(
+ f"/transcripts/{t.id}/speaker/assign",
+ json={
+ "speaker": 1,
+ "timestamp_from": 0.0,
+ "timestamp_to": 1.0,
+ },
+ )
+ assert resp.status_code == 401, resp.text
+
+
+# Minimal server fixture for websocket tests
+@pytest.fixture
+def appserver_ws_simple(setup_database):
+ host = "127.0.0.1"
+ port = 1256
+ server_started = threading.Event()
+ server_exception = None
+ server_instance = None
+
+ def run_server():
+ nonlocal server_exception, server_instance
+ try:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ config = Config(app=app, host=host, port=port, loop=loop)
+ server_instance = Server(config)
+
+ async def start_server():
+ database = get_database()
+ await database.connect()
+ try:
+ await server_instance.serve()
+ finally:
+ await database.disconnect()
+
+ server_started.set()
+ loop.run_until_complete(start_server())
+ except Exception as e:
+ server_exception = e
+ server_started.set()
+ finally:
+ loop.close()
+
+ server_thread = threading.Thread(target=run_server, daemon=True)
+ server_thread.start()
+
+ server_started.wait(timeout=30)
+ if server_exception:
+ raise server_exception
+
+ time.sleep(0.5)
+
+ yield host, port
+
+ if server_instance:
+ server_instance.should_exit = True
+ server_thread.join(timeout=30)
+
+
+@pytest.mark.asyncio
+async def test_websocket_denies_anonymous_on_private_transcript(appserver_ws_simple):
+ host, port = appserver_ws_simple
+
+ # Create a private transcript owned by someone
+ t = await transcripts_controller.add(
+ name="private-ws",
+ source_kind=SourceKind.LIVE,
+ user_id="owner-x",
+ share_mode="private",
+ )
+
+ base_url = f"http://{host}:{port}/v1"
+ # Anonymous connect should be denied
+ with pytest.raises(Exception):
+ async with aconnect_ws(f"{base_url}/transcripts/{t.id}/events") as ws:
+ await ws.close()
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_update_public_transcript(client):
+ t = await transcripts_controller.add(
+ name="update-me",
+ source_kind=SourceKind.LIVE,
+ user_id=None,
+ share_mode="public",
+ )
+
+ resp = await client.patch(
+ f"/transcripts/{t.id}",
+ json={"title": "New Title From Anonymous"},
+ )
+ assert resp.status_code == 401, resp.text
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_get_nonshared_room_by_id(client):
+ room = await rooms_controller.add(
+ name="private-room-exposed",
+ user_id="owner-z",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ webhook_url="",
+ webhook_secret="",
+ )
+
+ resp = await client.get(f"/rooms/{room.id}")
+ assert resp.status_code == 403, resp.text
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_call_rooms_webhook_test(client):
+ room = await rooms_controller.add(
+ name="room-webhook-test",
+ user_id="owner-y",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic-2nd-participant",
+ is_shared=False,
+ webhook_url="http://localhost.invalid/webhook",
+ webhook_secret="secret",
+ )
+
+ # Anonymous caller
+ resp = await client.post(f"/rooms/{room.id}/webhook/test")
+ assert resp.status_code == 401, resp.text
+
+
+@pytest.mark.asyncio
+async def test_anonymous_cannot_create_room(client):
+ payload = {
+ "name": "room-create-auth-required",
+ "zulip_auto_post": False,
+ "zulip_stream": "",
+ "zulip_topic": "",
+ "is_locked": False,
+ "room_mode": "normal",
+ "recording_type": "cloud",
+ "recording_trigger": "automatic-2nd-participant",
+ "is_shared": False,
+ "webhook_url": "",
+ "webhook_secret": "",
+ }
+ resp = await client.post("/rooms", json=payload)
+ assert resp.status_code == 401, resp.text
+
+
+@pytest.mark.asyncio
+async def test_list_search_401_when_public_mode_false(client, monkeypatch):
+ monkeypatch.setattr(settings, "PUBLIC_MODE", False)
+
+ resp = await client.get("/transcripts")
+ assert resp.status_code == 401
+
+ resp = await client.get("/transcripts/search", params={"q": "hello"})
+ assert resp.status_code == 401
+
+
+@pytest.mark.asyncio
+async def test_audio_mp3_requires_token_for_owned_transcript(
+ client, tmpdir, monkeypatch
+):
+ # Use temp data dir
+ monkeypatch.setattr(settings, "DATA_DIR", Path(tmpdir).as_posix())
+
+ # Create owner transcript and attach a local mp3
+ t = await transcripts_controller.add(
+ name="owned-audio",
+ source_kind=SourceKind.LIVE,
+ user_id="owner-a",
+ share_mode="private",
+ )
+
+ tr = await transcripts_controller.get_by_id(t.id)
+ await transcripts_controller.update(tr, {"status": "ended"})
+
+ # copy fixture audio to transcript path
+ audio_path = Path(__file__).parent / "records" / "test_mathieu_hello.mp3"
+ tr.audio_mp3_filename.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy(audio_path, tr.audio_mp3_filename)
+
+ # Anonymous GET without token should be 403 or 404 depending on access; we call mp3
+ resp = await client.get(f"/transcripts/{t.id}/audio/mp3")
+ assert resp.status_code == 403
+
+ # With token should succeed
+ token = create_access_token(
+ {"sub": tr.user_id}, expires_delta=__import__("datetime").timedelta(minutes=15)
+ )
+ resp2 = await client.get(f"/transcripts/{t.id}/audio/mp3", params={"token": token})
+ assert resp2.status_code == 200
diff --git a/server/tests/test_transcripts.py b/server/tests/test_transcripts.py
index 8ce0bd36..2c6acc77 100644
--- a/server/tests/test_transcripts.py
+++ b/server/tests/test_transcripts.py
@@ -1,5 +1,3 @@
-from contextlib import asynccontextmanager
-
import pytest
@@ -19,7 +17,7 @@ async def test_transcript_create(client):
@pytest.mark.asyncio
-async def test_transcript_get_update_name(client):
+async def test_transcript_get_update_name(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["name"] == "test"
@@ -40,7 +38,7 @@ async def test_transcript_get_update_name(client):
@pytest.mark.asyncio
-async def test_transcript_get_update_locked(client):
+async def test_transcript_get_update_locked(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["locked"] is False
@@ -61,7 +59,7 @@ async def test_transcript_get_update_locked(client):
@pytest.mark.asyncio
-async def test_transcript_get_update_summary(client):
+async def test_transcript_get_update_summary(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["long_summary"] is None
@@ -89,7 +87,7 @@ async def test_transcript_get_update_summary(client):
@pytest.mark.asyncio
-async def test_transcript_get_update_title(client):
+async def test_transcript_get_update_title(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["title"] is None
@@ -127,56 +125,6 @@ async def test_transcripts_list_anonymous(client):
settings.PUBLIC_MODE = False
-@asynccontextmanager
-async def authenticated_client_ctx():
- from reflector.app import app
- from reflector.auth import current_user, current_user_optional
-
- app.dependency_overrides[current_user] = lambda: {
- "sub": "randomuserid",
- "email": "test@mail.com",
- }
- app.dependency_overrides[current_user_optional] = lambda: {
- "sub": "randomuserid",
- "email": "test@mail.com",
- }
- yield
- del app.dependency_overrides[current_user]
- del app.dependency_overrides[current_user_optional]
-
-
-@asynccontextmanager
-async def authenticated_client2_ctx():
- from reflector.app import app
- from reflector.auth import current_user, current_user_optional
-
- app.dependency_overrides[current_user] = lambda: {
- "sub": "randomuserid2",
- "email": "test@mail.com",
- }
- app.dependency_overrides[current_user_optional] = lambda: {
- "sub": "randomuserid2",
- "email": "test@mail.com",
- }
- yield
- del app.dependency_overrides[current_user]
- del app.dependency_overrides[current_user_optional]
-
-
-@pytest.fixture
-@pytest.mark.asyncio
-async def authenticated_client():
- async with authenticated_client_ctx():
- yield
-
-
-@pytest.fixture
-@pytest.mark.asyncio
-async def authenticated_client2():
- async with authenticated_client2_ctx():
- yield
-
-
@pytest.mark.asyncio
async def test_transcripts_list_authenticated(authenticated_client, client):
# XXX this test is a bit fragile, as it depends on the storage which
@@ -199,7 +147,7 @@ async def test_transcripts_list_authenticated(authenticated_client, client):
@pytest.mark.asyncio
-async def test_transcript_delete(client):
+async def test_transcript_delete(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "testdel1"})
assert response.status_code == 200
assert response.json()["name"] == "testdel1"
@@ -214,7 +162,7 @@ async def test_transcript_delete(client):
@pytest.mark.asyncio
-async def test_transcript_mark_reviewed(client):
+async def test_transcript_mark_reviewed(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["name"] == "test"
diff --git a/server/tests/test_transcripts_audio_download.py b/server/tests/test_transcripts_audio_download.py
index e40d0ade..b7dcfca9 100644
--- a/server/tests/test_transcripts_audio_download.py
+++ b/server/tests/test_transcripts_audio_download.py
@@ -111,7 +111,9 @@ async def test_transcript_audio_download_range_with_seek(
@pytest.mark.asyncio
-async def test_transcript_delete_with_audio(fake_transcript, client):
+async def test_transcript_delete_with_audio(
+ authenticated_client, fake_transcript, client
+):
response = await client.delete(f"/transcripts/{fake_transcript.id}")
assert response.status_code == 200
assert response.json()["status"] == "ok"
diff --git a/server/tests/test_transcripts_participants.py b/server/tests/test_transcripts_participants.py
index 076f750e..24ec6a90 100644
--- a/server/tests/test_transcripts_participants.py
+++ b/server/tests/test_transcripts_participants.py
@@ -2,7 +2,7 @@ import pytest
@pytest.mark.asyncio
-async def test_transcript_participants(client):
+async def test_transcript_participants(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["participants"] == []
@@ -39,7 +39,7 @@ async def test_transcript_participants(client):
@pytest.mark.asyncio
-async def test_transcript_participants_same_speaker(client):
+async def test_transcript_participants_same_speaker(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["participants"] == []
@@ -62,7 +62,7 @@ async def test_transcript_participants_same_speaker(client):
@pytest.mark.asyncio
-async def test_transcript_participants_update_name(client):
+async def test_transcript_participants_update_name(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["participants"] == []
@@ -100,7 +100,7 @@ async def test_transcript_participants_update_name(client):
@pytest.mark.asyncio
-async def test_transcript_participants_update_speaker(client):
+async def test_transcript_participants_update_speaker(authenticated_client, client):
response = await client.post("/transcripts", json={"name": "test"})
assert response.status_code == 200
assert response.json()["participants"] == []
diff --git a/server/tests/test_transcripts_speaker.py b/server/tests/test_transcripts_speaker.py
index d18c5072..e85eb1c7 100644
--- a/server/tests/test_transcripts_speaker.py
+++ b/server/tests/test_transcripts_speaker.py
@@ -2,7 +2,9 @@ import pytest
@pytest.mark.asyncio
-async def test_transcript_reassign_speaker(fake_transcript_with_topics, client):
+async def test_transcript_reassign_speaker(
+ authenticated_client, fake_transcript_with_topics, client
+):
transcript_id = fake_transcript_with_topics.id
# check the transcript exists
@@ -114,7 +116,9 @@ async def test_transcript_reassign_speaker(fake_transcript_with_topics, client):
@pytest.mark.asyncio
-async def test_transcript_merge_speaker(fake_transcript_with_topics, client):
+async def test_transcript_merge_speaker(
+ authenticated_client, fake_transcript_with_topics, client
+):
transcript_id = fake_transcript_with_topics.id
# check the transcript exists
@@ -181,7 +185,7 @@ async def test_transcript_merge_speaker(fake_transcript_with_topics, client):
@pytest.mark.asyncio
async def test_transcript_reassign_with_participant(
- fake_transcript_with_topics, client
+ authenticated_client, fake_transcript_with_topics, client
):
transcript_id = fake_transcript_with_topics.id
@@ -347,7 +351,9 @@ async def test_transcript_reassign_with_participant(
@pytest.mark.asyncio
-async def test_transcript_reassign_edge_cases(fake_transcript_with_topics, client):
+async def test_transcript_reassign_edge_cases(
+ authenticated_client, fake_transcript_with_topics, client
+):
transcript_id = fake_transcript_with_topics.id
# check the transcript exists
diff --git a/www/app/(app)/transcripts/useWebSockets.ts b/www/app/(app)/transcripts/useWebSockets.ts
index 09426061..ed44577e 100644
--- a/www/app/(app)/transcripts/useWebSockets.ts
+++ b/www/app/(app)/transcripts/useWebSockets.ts
@@ -62,7 +62,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
useEffect(() => {
document.onkeyup = (e) => {
- if (e.key === "a" && process.env.NEXT_PUBLIC_ENV === "development") {
+ if (e.key === "a" && process.env.NODE_ENV === "development") {
const segments: GetTranscriptSegmentTopic[] = [
{
speaker: 1,
@@ -201,7 +201,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
setFinalSummary({ summary: "This is the final summary" });
}
- if (e.key === "z" && process.env.NEXT_PUBLIC_ENV === "development") {
+ if (e.key === "z" && process.env.NODE_ENV === "development") {
setTranscriptTextLive(
"This text is in English, and it is a pretty long sentence to test the limits",
);
From 1dee255fed9452aa1bd7a974d56424b48f0ca6ce Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Tue, 7 Oct 2025 10:41:01 -0400
Subject: [PATCH 52/77] parakeet endpoint doc (#679)
Co-authored-by: Igor Loskutov
---
server/env.example | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/env.example b/server/env.example
index 70b4b229..ff0f4211 100644
--- a/server/env.example
+++ b/server/env.example
@@ -27,7 +27,7 @@ AUTH_JWT_AUDIENCE=
#TRANSCRIPT_MODAL_API_KEY=xxxxx
TRANSCRIPT_BACKEND=modal
-TRANSCRIPT_URL=https://monadical-sas--reflector-transcriber-web.modal.run
+TRANSCRIPT_URL=https://monadical-sas--reflector-transcriber-parakeet-web.modal.run
TRANSCRIPT_MODAL_API_KEY=
## =======================================================
From eef6dc39037329b65804297786d852dddb0557f9 Mon Sep 17 00:00:00 2001
From: Sergey Mankovsky
Date: Tue, 7 Oct 2025 16:45:02 +0200
Subject: [PATCH 53/77] fix: upgrade nemo toolkit (#678)
---
gpu/modal_deployments/reflector_transcriber_parakeet.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/gpu/modal_deployments/reflector_transcriber_parakeet.py b/gpu/modal_deployments/reflector_transcriber_parakeet.py
index 0827f0cc..947fccca 100644
--- a/gpu/modal_deployments/reflector_transcriber_parakeet.py
+++ b/gpu/modal_deployments/reflector_transcriber_parakeet.py
@@ -77,7 +77,7 @@ image = (
.pip_install(
"hf_transfer==0.1.9",
"huggingface_hub[hf-xet]==0.31.2",
- "nemo_toolkit[asr]==2.3.0",
+ "nemo_toolkit[asr]==2.5.0",
"cuda-python==12.8.0",
"fastapi==0.115.12",
"numpy<2",
From 9a71af145ee9b833078c78d0c684590ab12e9f0e Mon Sep 17 00:00:00 2001
From: Sergey Mankovsky
Date: Tue, 7 Oct 2025 19:11:30 +0200
Subject: [PATCH 54/77] fix: update transcript list on reprocess (#676)
* Update transcript list on reprocess
* Fix transcript create
* Fix multiple sockets issue
* Pass token in sec websocket protocol
* userEvent parse example
* transcript list invalidation non-abstraction
* Emit only relevant events to the user room
* Add ws close code const
* Refactor user websocket endpoint
* Refactor user events provider
---------
Co-authored-by: Igor Loskutov
---
server/reflector/app.py | 2 +
.../reflector/pipelines/main_file_pipeline.py | 2 +-
.../reflector/pipelines/main_live_pipeline.py | 14 ++
server/reflector/views/transcripts.py | 15 +-
server/reflector/views/user_websocket.py | 53 ++++++
server/reflector/ws_manager.py | 9 +-
server/tests/conftest.py | 4 +
server/tests/test_user_websocket_auth.py | 156 +++++++++++++++
www/app/lib/UserEventsProvider.tsx | 180 ++++++++++++++++++
www/app/lib/apiHooks.ts | 15 +-
www/app/providers.tsx | 11 +-
11 files changed, 449 insertions(+), 12 deletions(-)
create mode 100644 server/reflector/views/user_websocket.py
create mode 100644 server/tests/test_user_websocket_auth.py
create mode 100644 www/app/lib/UserEventsProvider.tsx
diff --git a/server/reflector/app.py b/server/reflector/app.py
index 609474a2..8c8724a6 100644
--- a/server/reflector/app.py
+++ b/server/reflector/app.py
@@ -26,6 +26,7 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout
from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router
from reflector.views.transcripts_websocket import router as transcripts_websocket_router
from reflector.views.user import router as user_router
+from reflector.views.user_websocket import router as user_ws_router
from reflector.views.whereby import router as whereby_router
from reflector.views.zulip import router as zulip_router
@@ -90,6 +91,7 @@ app.include_router(transcripts_websocket_router, prefix="/v1")
app.include_router(transcripts_webrtc_router, prefix="/v1")
app.include_router(transcripts_process_router, prefix="/v1")
app.include_router(user_router, prefix="/v1")
+app.include_router(user_ws_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1")
add_pagination(app)
diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py
index ce9d000e..bbf23e7b 100644
--- a/server/reflector/pipelines/main_file_pipeline.py
+++ b/server/reflector/pipelines/main_file_pipeline.py
@@ -131,7 +131,7 @@ class PipelineMainFile(PipelineMainBase):
self.logger.info("File pipeline complete")
- await transcripts_controller.set_status(transcript.id, "ended")
+ await self.set_status(transcript.id, "ended")
async def extract_and_write_audio(
self, file_path: Path, transcript: Transcript
diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py
index 64904952..f6fe6a83 100644
--- a/server/reflector/pipelines/main_live_pipeline.py
+++ b/server/reflector/pipelines/main_live_pipeline.py
@@ -85,6 +85,20 @@ def broadcast_to_sockets(func):
message=resp.model_dump(mode="json"),
)
+ transcript = await transcripts_controller.get_by_id(self.transcript_id)
+ if transcript and transcript.user_id:
+ # Emit only relevant events to the user room to avoid noisy updates.
+ # Allowed: STATUS, FINAL_TITLE, DURATION. All are prefixed with TRANSCRIPT_
+ allowed_user_events = {"STATUS", "FINAL_TITLE", "DURATION"}
+ if resp.event in allowed_user_events:
+ await self.ws_manager.send_json(
+ room_id=f"user:{transcript.user_id}",
+ message={
+ "event": f"TRANSCRIPT_{resp.event}",
+ "data": {"id": self.transcript_id, **resp.data},
+ },
+ )
+
return wrapper
diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py
index 839c6cdb..04d27e1a 100644
--- a/server/reflector/views/transcripts.py
+++ b/server/reflector/views/transcripts.py
@@ -32,6 +32,7 @@ from reflector.db.transcripts import (
from reflector.processors.types import Transcript as ProcessorTranscript
from reflector.processors.types import Word
from reflector.settings import settings
+from reflector.ws_manager import get_ws_manager
from reflector.zulip import (
InvalidMessageError,
get_zulip_message,
@@ -211,7 +212,7 @@ async def transcripts_create(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
user_id = user["sub"] if user else None
- return await transcripts_controller.add(
+ transcript = await transcripts_controller.add(
info.name,
source_kind=info.source_kind or SourceKind.LIVE,
source_language=info.source_language,
@@ -219,6 +220,14 @@ async def transcripts_create(
user_id=user_id,
)
+ if user_id:
+ await get_ws_manager().send_json(
+ room_id=f"user:{user_id}",
+ message={"event": "TRANSCRIPT_CREATED", "data": {"id": transcript.id}},
+ )
+
+ return transcript
+
# ==============================================================
# Single transcript
@@ -368,6 +377,10 @@ async def transcript_delete(
raise HTTPException(status_code=403, detail="Not authorized")
await transcripts_controller.remove_by_id(transcript.id, user_id=user_id)
+ await get_ws_manager().send_json(
+ room_id=f"user:{user_id}",
+ message={"event": "TRANSCRIPT_DELETED", "data": {"id": transcript.id}},
+ )
return DeletionStatus(status="ok")
diff --git a/server/reflector/views/user_websocket.py b/server/reflector/views/user_websocket.py
new file mode 100644
index 00000000..26d3c8ac
--- /dev/null
+++ b/server/reflector/views/user_websocket.py
@@ -0,0 +1,53 @@
+from typing import Optional
+
+from fastapi import APIRouter, WebSocket
+
+from reflector.auth.auth_jwt import JWTAuth # type: ignore
+from reflector.ws_manager import get_ws_manager
+
+router = APIRouter()
+
+# Close code for unauthorized WebSocket connections
+UNAUTHORISED = 4401
+
+
+@router.websocket("/events")
+async def user_events_websocket(websocket: WebSocket):
+ # Browser can't send Authorization header for WS; use subprotocol: ["bearer", token]
+ raw_subprotocol = websocket.headers.get("sec-websocket-protocol") or ""
+ parts = [p.strip() for p in raw_subprotocol.split(",") if p.strip()]
+ token: Optional[str] = None
+ negotiated_subprotocol: Optional[str] = None
+ if len(parts) >= 2 and parts[0].lower() == "bearer":
+ negotiated_subprotocol = "bearer"
+ token = parts[1]
+
+ user_id: Optional[str] = None
+ if not token:
+ await websocket.close(code=UNAUTHORISED)
+ return
+
+ try:
+ payload = JWTAuth().verify_token(token)
+ user_id = payload.get("sub")
+ except Exception:
+ await websocket.close(code=UNAUTHORISED)
+ return
+
+ if not user_id:
+ await websocket.close(code=UNAUTHORISED)
+ return
+
+ room_id = f"user:{user_id}"
+ ws_manager = get_ws_manager()
+
+ await ws_manager.add_user_to_room(
+ room_id, websocket, subprotocol=negotiated_subprotocol
+ )
+
+ try:
+ while True:
+ await websocket.receive()
+ finally:
+ if room_id:
+ await ws_manager.remove_user_from_room(room_id, websocket)
diff --git a/server/reflector/ws_manager.py b/server/reflector/ws_manager.py
index 07790e09..a1f620c4 100644
--- a/server/reflector/ws_manager.py
+++ b/server/reflector/ws_manager.py
@@ -65,8 +65,13 @@ class WebsocketManager:
self.tasks: dict = {}
self.pubsub_client = pubsub_client
- async def add_user_to_room(self, room_id: str, websocket: WebSocket) -> None:
- await websocket.accept()
+ async def add_user_to_room(
+ self, room_id: str, websocket: WebSocket, subprotocol: str | None = None
+ ) -> None:
+ if subprotocol:
+ await websocket.accept(subprotocol=subprotocol)
+ else:
+ await websocket.accept()
if room_id in self.rooms:
self.rooms[room_id].append(websocket)
diff --git a/server/tests/conftest.py b/server/tests/conftest.py
index 8271d1ad..a70604ae 100644
--- a/server/tests/conftest.py
+++ b/server/tests/conftest.py
@@ -398,6 +398,10 @@ async def ws_manager_in_memory(monkeypatch):
monkeypatch.setattr(
"reflector.views.transcripts_websocket.get_ws_manager", _get_ws_manager
)
+ monkeypatch.setattr(
+ "reflector.views.user_websocket.get_ws_manager", _get_ws_manager
+ )
+ monkeypatch.setattr("reflector.views.transcripts.get_ws_manager", _get_ws_manager)
# Websocket auth: avoid OAuth2 on websocket dependencies; allow anonymous
import reflector.auth as auth
diff --git a/server/tests/test_user_websocket_auth.py b/server/tests/test_user_websocket_auth.py
new file mode 100644
index 00000000..be1a2816
--- /dev/null
+++ b/server/tests/test_user_websocket_auth.py
@@ -0,0 +1,156 @@
+import asyncio
+import threading
+import time
+
+import pytest
+from httpx_ws import aconnect_ws
+from uvicorn import Config, Server
+
+
+@pytest.fixture
+def appserver_ws_user(setup_database):
+ from reflector.app import app
+ from reflector.db import get_database
+
+ host = "127.0.0.1"
+ port = 1257
+ server_started = threading.Event()
+ server_exception = None
+ server_instance = None
+
+ def run_server():
+ nonlocal server_exception, server_instance
+ try:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+
+ config = Config(app=app, host=host, port=port, loop=loop)
+ server_instance = Server(config)
+
+ async def start_server():
+ database = get_database()
+ await database.connect()
+ try:
+ await server_instance.serve()
+ finally:
+ await database.disconnect()
+
+ server_started.set()
+ loop.run_until_complete(start_server())
+ except Exception as e:
+ server_exception = e
+ server_started.set()
+ finally:
+ loop.close()
+
+ server_thread = threading.Thread(target=run_server, daemon=True)
+ server_thread.start()
+
+ server_started.wait(timeout=30)
+ if server_exception:
+ raise server_exception
+
+ time.sleep(0.5)
+
+ yield host, port
+
+ if server_instance:
+ server_instance.should_exit = True
+ server_thread.join(timeout=30)
+
+
+@pytest.fixture(autouse=True)
+def patch_jwt_verification(monkeypatch):
+ """Patch JWT verification to accept HS256 tokens signed with SECRET_KEY for tests."""
+ from jose import jwt
+
+ from reflector.settings import settings
+
+ def _verify_token(self, token: str):
+ # Do not validate audience in tests
+ return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"]) # type: ignore[arg-type]
+
+ monkeypatch.setattr(
+ "reflector.auth.auth_jwt.JWTAuth.verify_token", _verify_token, raising=True
+ )
+
+
+def _make_dummy_jwt(sub: str = "user123") -> str:
+ # Create a short HS256 JWT using the app secret to pass verification in tests
+ from datetime import datetime, timedelta, timezone
+
+ from jose import jwt
+
+ from reflector.settings import settings
+
+ payload = {
+ "sub": sub,
+ "email": f"{sub}@example.com",
+ "exp": datetime.now(timezone.utc) + timedelta(minutes=5),
+ }
+ # Note: production uses RS256 public key verification; tests can sign with SECRET_KEY
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
+
+
+@pytest.mark.asyncio
+async def test_user_ws_rejects_missing_subprotocol(appserver_ws_user):
+ host, port = appserver_ws_user
+ base_ws = f"http://{host}:{port}/v1/events"
+ # No subprotocol/header with token
+ with pytest.raises(Exception):
+ async with aconnect_ws(base_ws) as ws: # type: ignore
+ # Should close during handshake; if not, close explicitly
+ await ws.close()
+
+
+@pytest.mark.asyncio
+async def test_user_ws_rejects_invalid_token(appserver_ws_user):
+ host, port = appserver_ws_user
+ base_ws = f"http://{host}:{port}/v1/events"
+
+ # Send wrong token via WebSocket subprotocols
+ protocols = ["bearer", "totally-invalid-token"]
+ with pytest.raises(Exception):
+ async with aconnect_ws(base_ws, subprotocols=protocols) as ws: # type: ignore
+ await ws.close()
+
+
+@pytest.mark.asyncio
+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")
+ subprotocols = ["bearer", token]
+
+ # Connect and then trigger an event via HTTP create
+ async with aconnect_ws(base_ws, subprotocols=subprotocols) as ws:
+ # Emit an event to the user's room via a standard HTTP action
+ from httpx import AsyncClient
+
+ from reflector.app import app
+ from reflector.auth import current_user, current_user_optional
+
+ # Override auth dependencies so HTTP request is performed as the same user
+ app.dependency_overrides[current_user] = lambda: {
+ "sub": "user-abc",
+ "email": "user-abc@example.com",
+ }
+ app.dependency_overrides[current_user_optional] = lambda: {
+ "sub": "user-abc",
+ "email": "user-abc@example.com",
+ }
+
+ async with AsyncClient(app=app, base_url=f"http://{host}:{port}/v1") as ac:
+ # Create a transcript as this user so that the server publishes TRANSCRIPT_CREATED to user room
+ resp = await ac.post("/transcripts", json={"name": "WS Test"})
+ assert resp.status_code == 200
+
+ # Receive the published event
+ msg = await ws.receive_json()
+ assert msg["event"] == "TRANSCRIPT_CREATED"
+ assert "id" in msg["data"]
+
+ # Clean overrides
+ del app.dependency_overrides[current_user]
+ del app.dependency_overrides[current_user_optional]
diff --git a/www/app/lib/UserEventsProvider.tsx b/www/app/lib/UserEventsProvider.tsx
new file mode 100644
index 00000000..89ec5a11
--- /dev/null
+++ b/www/app/lib/UserEventsProvider.tsx
@@ -0,0 +1,180 @@
+"use client";
+
+import React, { useEffect, useRef } from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import { WEBSOCKET_URL } from "./apiClient";
+import { useAuth } from "./AuthProvider";
+import { z } from "zod";
+import { invalidateTranscriptLists, TRANSCRIPT_SEARCH_URL } from "./apiHooks";
+
+const UserEvent = z.object({
+ event: z.string(),
+});
+
+type UserEvent = z.TypeOf;
+
+class UserEventsStore {
+ private socket: WebSocket | null = null;
+ private listeners: Set<(event: MessageEvent) => void> = new Set();
+ private closeTimeoutId: number | null = null;
+ private isConnecting = false;
+
+ ensureConnection(url: string, subprotocols?: string[]) {
+ if (typeof window === "undefined") return;
+ if (this.closeTimeoutId !== null) {
+ clearTimeout(this.closeTimeoutId);
+ this.closeTimeoutId = null;
+ }
+ if (this.isConnecting) return;
+ if (
+ this.socket &&
+ (this.socket.readyState === WebSocket.OPEN ||
+ this.socket.readyState === WebSocket.CONNECTING)
+ ) {
+ return;
+ }
+ this.isConnecting = true;
+ const ws = new WebSocket(url, subprotocols || []);
+ this.socket = ws;
+ ws.onmessage = (event: MessageEvent) => {
+ this.listeners.forEach((listener) => {
+ try {
+ listener(event);
+ } catch (err) {
+ console.error("UserEvents listener error", err);
+ }
+ });
+ };
+ ws.onopen = () => {
+ if (this.socket === ws) this.isConnecting = false;
+ };
+ ws.onclose = () => {
+ if (this.socket === ws) {
+ this.socket = null;
+ this.isConnecting = false;
+ }
+ };
+ ws.onerror = () => {
+ if (this.socket === ws) this.isConnecting = false;
+ };
+ }
+
+ subscribe(listener: (event: MessageEvent) => void): () => void {
+ this.listeners.add(listener);
+ if (this.closeTimeoutId !== null) {
+ clearTimeout(this.closeTimeoutId);
+ this.closeTimeoutId = null;
+ }
+ return () => {
+ this.listeners.delete(listener);
+ if (this.listeners.size === 0) {
+ this.closeTimeoutId = window.setTimeout(() => {
+ if (this.socket) {
+ try {
+ this.socket.close();
+ } catch (err) {
+ console.warn("Error closing user events socket", err);
+ }
+ }
+ this.socket = null;
+ this.closeTimeoutId = null;
+ }, 1000);
+ }
+ };
+ }
+}
+
+const sharedStore = new UserEventsStore();
+
+export function UserEventsProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const auth = useAuth();
+ const queryClient = useQueryClient();
+ const tokenRef = useRef(null);
+ const detachRef = useRef<(() => void) | null>(null);
+
+ useEffect(() => {
+ // Only tear down when the user is truly unauthenticated
+ if (auth.status === "unauthenticated") {
+ if (detachRef.current) {
+ try {
+ detachRef.current();
+ } catch (err) {
+ console.warn("Error detaching UserEvents listener", err);
+ }
+ detachRef.current = null;
+ }
+ tokenRef.current = null;
+ return;
+ }
+
+ // During loading/refreshing, keep the existing connection intact
+ if (auth.status !== "authenticated") {
+ return;
+ }
+
+ // Authenticated: pin the initial token for the lifetime of this WS connection
+ if (!tokenRef.current && auth.accessToken) {
+ tokenRef.current = auth.accessToken;
+ }
+ const pinnedToken = tokenRef.current;
+ const url = `${WEBSOCKET_URL}/v1/events`;
+
+ // Ensure a single shared connection
+ sharedStore.ensureConnection(
+ url,
+ pinnedToken ? ["bearer", pinnedToken] : undefined,
+ );
+
+ // Subscribe once; avoid re-subscribing during transient status changes
+ if (!detachRef.current) {
+ const onMessage = (event: MessageEvent) => {
+ try {
+ const msg = UserEvent.parse(JSON.parse(event.data));
+ const eventName = msg.event;
+
+ const invalidateList = () => invalidateTranscriptLists(queryClient);
+
+ switch (eventName) {
+ case "TRANSCRIPT_CREATED":
+ case "TRANSCRIPT_DELETED":
+ case "TRANSCRIPT_STATUS":
+ case "TRANSCRIPT_FINAL_TITLE":
+ case "TRANSCRIPT_DURATION":
+ invalidateList().then(() => {});
+ break;
+
+ default:
+ // Ignore other content events for list updates
+ break;
+ }
+ } catch (err) {
+ console.warn("Invalid user event message", event.data);
+ }
+ };
+
+ const unsubscribe = sharedStore.subscribe(onMessage);
+ detachRef.current = unsubscribe;
+ }
+ }, [auth.status, queryClient]);
+
+ // On unmount, detach the listener and clear the pinned token
+ useEffect(() => {
+ return () => {
+ if (detachRef.current) {
+ try {
+ detachRef.current();
+ } catch (err) {
+ console.warn("Error detaching UserEvents listener on unmount", err);
+ }
+ detachRef.current = null;
+ }
+ tokenRef.current = null;
+ };
+ }, []);
+
+ return <>{children}>;
+}
diff --git a/www/app/lib/apiHooks.ts b/www/app/lib/apiHooks.ts
index c5b4f9b9..726e5441 100644
--- a/www/app/lib/apiHooks.ts
+++ b/www/app/lib/apiHooks.ts
@@ -2,7 +2,7 @@
import { $api } from "./apiClient";
import { useError } from "../(errors)/errorContext";
-import { useQueryClient } from "@tanstack/react-query";
+import { QueryClient, useQueryClient } from "@tanstack/react-query";
import type { components } from "../reflector-api";
import { useAuth } from "./AuthProvider";
@@ -40,6 +40,13 @@ export function useRoomsList(page: number = 1) {
type SourceKind = components["schemas"]["SourceKind"];
+export const TRANSCRIPT_SEARCH_URL = "/v1/transcripts/search" as const;
+
+export const invalidateTranscriptLists = (queryClient: QueryClient) =>
+ queryClient.invalidateQueries({
+ queryKey: ["get", TRANSCRIPT_SEARCH_URL],
+ });
+
export function useTranscriptsSearch(
q: string = "",
options: {
@@ -51,7 +58,7 @@ export function useTranscriptsSearch(
) {
return $api.useQuery(
"get",
- "/v1/transcripts/search",
+ TRANSCRIPT_SEARCH_URL,
{
params: {
query: {
@@ -76,7 +83,7 @@ export function useTranscriptDelete() {
return $api.useMutation("delete", "/v1/transcripts/{transcript_id}", {
onSuccess: () => {
return queryClient.invalidateQueries({
- queryKey: ["get", "/v1/transcripts/search"],
+ queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
},
onError: (error) => {
@@ -613,7 +620,7 @@ export function useTranscriptCreate() {
return $api.useMutation("post", "/v1/transcripts", {
onSuccess: () => {
return queryClient.invalidateQueries({
- queryKey: ["get", "/v1/transcripts/search"],
+ queryKey: ["get", TRANSCRIPT_SEARCH_URL],
});
},
onError: (error) => {
diff --git a/www/app/providers.tsx b/www/app/providers.tsx
index 37b37a0e..6e689812 100644
--- a/www/app/providers.tsx
+++ b/www/app/providers.tsx
@@ -11,6 +11,7 @@ import { queryClient } from "./lib/queryClient";
import { AuthProvider } from "./lib/AuthProvider";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
import { RecordingConsentProvider } from "./recordingConsentContext";
+import { UserEventsProvider } from "./lib/UserEventsProvider";
const WherebyProvider = dynamic(
() =>
@@ -28,10 +29,12 @@ export function Providers({ children }: { children: React.ReactNode }) {
-
- {children}
-
-
+
+
+ {children}
+
+
+
From 5f6910e5131b7f28f86c9ecdcc57fed8412ee3cd Mon Sep 17 00:00:00 2001
From: Jose
Date: Wed, 8 Oct 2025 11:11:57 -0500
Subject: [PATCH 55/77] feat: Add calendar event data to transcript webhook
payload (#689)
* feat: add calendar event data to transcript webhook payload and implement get_by_id method
* Update server/reflector/worker/webhook.py
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
* Update server/reflector/worker/webhook.py
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
* style: format conditional time fields with line breaks for better readability
* docs: add calendar event fields to transcript.completed webhook payload schema
---------
Co-authored-by: pr-agent-monadical[bot] <198624643+pr-agent-monadical[bot]@users.noreply.github.com>
---
server/docs/webhook.md | 23 ++++++++++++++-
server/reflector/db/calendar_events.py | 5 ++++
server/reflector/worker/webhook.py | 41 ++++++++++++++++++++++++++
3 files changed, 68 insertions(+), 1 deletion(-)
diff --git a/server/docs/webhook.md b/server/docs/webhook.md
index 9fe88fb9..b103d655 100644
--- a/server/docs/webhook.md
+++ b/server/docs/webhook.md
@@ -14,7 +14,7 @@ Webhooks are configured at the room level with two fields:
### `transcript.completed`
-Triggered when a transcript has been fully processed, including transcription, diarization, summarization, and topic detection.
+Triggered when a transcript has been fully processed, including transcription, diarization, summarization, topic detection and calendar event integration.
### `test`
@@ -128,6 +128,27 @@ This event includes a convenient URL for accessing the transcript:
"room": {
"id": "room-789",
"name": "Product Team Room"
+ },
+ "calendar_event": {
+ "id": "calendar-event-123",
+ "ics_uid": "event-123",
+ "title": "Q3 Product Planning Meeting",
+ "start_time": "2025-08-27T12:00:00Z",
+ "end_time": "2025-08-27T12:30:00Z",
+ "description": "Team discussed Q3 product roadmap, prioritizing mobile app features and API improvements.",
+ "location": "Conference Room 1",
+ "attendees": [
+ {
+ "id": "participant-1",
+ "name": "John Doe",
+ "speaker": "Speaker 1"
+ },
+ {
+ "id": "participant-2",
+ "name": "Jane Smith",
+ "speaker": "Speaker 2"
+ }
+ ]
}
}
```
diff --git a/server/reflector/db/calendar_events.py b/server/reflector/db/calendar_events.py
index 4a88d126..3eddc3f1 100644
--- a/server/reflector/db/calendar_events.py
+++ b/server/reflector/db/calendar_events.py
@@ -104,6 +104,11 @@ class CalendarEventController:
results = await get_database().fetch_all(query)
return [CalendarEvent(**result) for result in results]
+ async def get_by_id(self, event_id: str) -> CalendarEvent | None:
+ query = calendar_events.select().where(calendar_events.c.id == event_id)
+ result = await get_database().fetch_one(query)
+ return CalendarEvent(**result) if result else None
+
async def get_by_ics_uid(self, room_id: str, ics_uid: str) -> CalendarEvent | None:
query = calendar_events.select().where(
sa.and_(
diff --git a/server/reflector/worker/webhook.py b/server/reflector/worker/webhook.py
index 64368b2e..57b294d8 100644
--- a/server/reflector/worker/webhook.py
+++ b/server/reflector/worker/webhook.py
@@ -11,6 +11,8 @@ import structlog
from celery import shared_task
from celery.utils.log import get_task_logger
+from reflector.db.calendar_events import calendar_events_controller
+from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.db.transcripts import transcripts_controller
from reflector.pipelines.main_live_pipeline import asynctask
@@ -84,6 +86,18 @@ async def send_transcript_webhook(
}
)
+ # Fetch meeting and calendar event if they exist
+ calendar_event = None
+ try:
+ if transcript.meeting_id:
+ meeting = await meetings_controller.get_by_id(transcript.meeting_id)
+ if meeting and meeting.calendar_event_id:
+ calendar_event = await calendar_events_controller.get_by_id(
+ meeting.calendar_event_id
+ )
+ except Exception as e:
+ logger.error("Error fetching meeting or calendar event", error=str(e))
+
# Build webhook payload
frontend_url = f"{settings.UI_BASE_URL}/transcripts/{transcript.id}"
participants = [
@@ -116,6 +130,33 @@ async def send_transcript_webhook(
},
}
+ # Always include calendar_event field, even if no event is present
+ payload_data["calendar_event"] = {}
+
+ # Add calendar event data if present
+ if calendar_event:
+ calendar_data = {
+ "id": calendar_event.id,
+ "ics_uid": calendar_event.ics_uid,
+ "title": calendar_event.title,
+ "start_time": calendar_event.start_time.isoformat()
+ if calendar_event.start_time
+ else None,
+ "end_time": calendar_event.end_time.isoformat()
+ if calendar_event.end_time
+ else None,
+ }
+
+ # Add optional fields only if they exist
+ if calendar_event.description:
+ calendar_data["description"] = calendar_event.description
+ if calendar_event.location:
+ calendar_data["location"] = calendar_event.location
+ if calendar_event.attendees:
+ calendar_data["attendees"] = calendar_event.attendees
+
+ payload_data["calendar_event"] = calendar_data
+
# Convert to JSON
payload_json = json.dumps(payload_data, separators=(",", ":"))
payload_bytes = payload_json.encode("utf-8")
From af86c47f1dafdffcebc37ccfe0b88f91e8565be1 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Wed, 8 Oct 2025 14:57:31 -0600
Subject: [PATCH 56/77] chore(main): release 0.14.0 (#670)
---
CHANGELOG.md | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 83c58a06..fab04f86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,22 @@
# Changelog
+## [0.14.0](https://github.com/Monadical-SAS/reflector/compare/v0.13.1...v0.14.0) (2025-10-08)
+
+
+### Features
+
+* Add calendar event data to transcript webhook payload ([#689](https://github.com/Monadical-SAS/reflector/issues/689)) ([5f6910e](https://github.com/Monadical-SAS/reflector/commit/5f6910e5131b7f28f86c9ecdcc57fed8412ee3cd))
+* container build for www / github ([#672](https://github.com/Monadical-SAS/reflector/issues/672)) ([969bd84](https://github.com/Monadical-SAS/reflector/commit/969bd84fcc14851d1a101412a0ba115f1b7cde82))
+* docker-compose for production frontend ([#664](https://github.com/Monadical-SAS/reflector/issues/664)) ([5bf64b5](https://github.com/Monadical-SAS/reflector/commit/5bf64b5a41f64535e22849b4bb11734d4dbb4aae))
+
+
+### Bug Fixes
+
+* restore feature boolean logic ([#671](https://github.com/Monadical-SAS/reflector/issues/671)) ([3660884](https://github.com/Monadical-SAS/reflector/commit/36608849ec64e953e3be456172502762e3c33df9))
+* security review ([#656](https://github.com/Monadical-SAS/reflector/issues/656)) ([5d98754](https://github.com/Monadical-SAS/reflector/commit/5d98754305c6c540dd194dda268544f6d88bfaf8))
+* update transcript list on reprocess ([#676](https://github.com/Monadical-SAS/reflector/issues/676)) ([9a71af1](https://github.com/Monadical-SAS/reflector/commit/9a71af145ee9b833078c78d0c684590ab12e9f0e))
+* upgrade nemo toolkit ([#678](https://github.com/Monadical-SAS/reflector/issues/678)) ([eef6dc3](https://github.com/Monadical-SAS/reflector/commit/eef6dc39037329b65804297786d852dddb0557f9))
+
## [0.13.1](https://github.com/Monadical-SAS/reflector/compare/v0.13.0...v0.13.1) (2025-09-22)
From 9a258abc0209b0ac3799532a507ea6a9125d703a Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Mon, 20 Oct 2025 12:55:25 -0400
Subject: [PATCH 57/77] feat: api tokens (#705)
* feat: api tokens (vibe)
* self-review
* remove token terminology + pr comments (vibe)
* return email_verified
---------
Co-authored-by: Igor Loskutov
---
server/README.md | 26 +++
.../9e3f7b2a4c8e_add_user_api_keys.py | 38 ++++
server/reflector/app.py | 2 +
server/reflector/auth/auth_jwt.py | 83 +++++---
server/reflector/db/__init__.py | 1 +
server/reflector/db/user_api_keys.py | 90 +++++++++
server/reflector/views/user_api_keys.py | 62 ++++++
server/tests/test_user_api_keys.py | 70 +++++++
www/app/reflector-api.d.ts | 191 ++++++++++++++++++
9 files changed, 532 insertions(+), 31 deletions(-)
create mode 100644 server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py
create mode 100644 server/reflector/db/user_api_keys.py
create mode 100644 server/reflector/views/user_api_keys.py
create mode 100644 server/tests/test_user_api_keys.py
diff --git a/server/README.md b/server/README.md
index f91a49bf..c078f493 100644
--- a/server/README.md
+++ b/server/README.md
@@ -1,3 +1,29 @@
+## API Key Management
+
+### Finding Your User ID
+
+```bash
+# Get your OAuth sub (user ID) - requires authentication
+curl -H "Authorization: Bearer " http://localhost:1250/v1/me
+# Returns: {"sub": "your-oauth-sub-here", "email": "...", ...}
+```
+
+### Creating API Keys
+
+```bash
+curl -X POST http://localhost:1250/v1/user/api-keys \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{"name": "My API Key"}'
+```
+
+### Using API Keys
+
+```bash
+# Use X-API-Key header instead of Authorization
+curl -H "X-API-Key: " http://localhost:1250/v1/transcripts
+```
+
## AWS S3/SQS usage clarification
Whereby.com uploads recordings directly to our S3 bucket when meetings end.
diff --git a/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py b/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py
new file mode 100644
index 00000000..ef8f881c
--- /dev/null
+++ b/server/migrations/versions/9e3f7b2a4c8e_add_user_api_keys.py
@@ -0,0 +1,38 @@
+"""add user api keys
+
+Revision ID: 9e3f7b2a4c8e
+Revises: dc035ff72fd5
+Create Date: 2025-10-17 00:00:00.000000
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "9e3f7b2a4c8e"
+down_revision: Union[str, None] = "dc035ff72fd5"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "user_api_key",
+ sa.Column("id", sa.String(), nullable=False),
+ sa.Column("user_id", sa.String(), nullable=False),
+ sa.Column("key_hash", sa.String(), nullable=False),
+ sa.Column("name", sa.String(), nullable=True),
+ sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+ with op.batch_alter_table("user_api_key", schema=None) as batch_op:
+ batch_op.create_index("idx_user_api_key_hash", ["key_hash"], unique=True)
+ batch_op.create_index("idx_user_api_key_user_id", ["user_id"], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_table("user_api_key")
diff --git a/server/reflector/app.py b/server/reflector/app.py
index 8c8724a6..a15934f5 100644
--- a/server/reflector/app.py
+++ b/server/reflector/app.py
@@ -26,6 +26,7 @@ from reflector.views.transcripts_upload import router as transcripts_upload_rout
from reflector.views.transcripts_webrtc import router as transcripts_webrtc_router
from reflector.views.transcripts_websocket import router as transcripts_websocket_router
from reflector.views.user import router as user_router
+from reflector.views.user_api_keys import router as user_api_keys_router
from reflector.views.user_websocket import router as user_ws_router
from reflector.views.whereby import router as whereby_router
from reflector.views.zulip import router as zulip_router
@@ -91,6 +92,7 @@ app.include_router(transcripts_websocket_router, prefix="/v1")
app.include_router(transcripts_webrtc_router, prefix="/v1")
app.include_router(transcripts_process_router, prefix="/v1")
app.include_router(user_router, prefix="/v1")
+app.include_router(user_api_keys_router, prefix="/v1")
app.include_router(user_ws_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1")
diff --git a/server/reflector/auth/auth_jwt.py b/server/reflector/auth/auth_jwt.py
index 309ab3f7..0dcff9a0 100644
--- a/server/reflector/auth/auth_jwt.py
+++ b/server/reflector/auth/auth_jwt.py
@@ -1,14 +1,16 @@
-from typing import Annotated, Optional
+from typing import Annotated, List, Optional
from fastapi import Depends, HTTPException
-from fastapi.security import OAuth2PasswordBearer
+from fastapi.security import APIKeyHeader, OAuth2PasswordBearer
from jose import JWTError, jwt
from pydantic import BaseModel
+from reflector.db.user_api_keys import user_api_keys_controller
from reflector.logger import logger
from reflector.settings import settings
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
+api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
jwt_public_key = open(f"reflector/auth/jwt/keys/{settings.AUTH_JWT_PUBLIC_KEY}").read()
jwt_algorithm = settings.AUTH_JWT_ALGORITHM
@@ -26,7 +28,7 @@ class JWTException(Exception):
class UserInfo(BaseModel):
sub: str
- email: str
+ email: Optional[str] = None
def __getitem__(self, key):
return getattr(self, key)
@@ -58,34 +60,53 @@ def authenticated(token: Annotated[str, Depends(oauth2_scheme)]):
return None
-def current_user(
- token: Annotated[Optional[str], Depends(oauth2_scheme)],
- jwtauth: JWTAuth = Depends(),
-):
- if token is None:
- raise HTTPException(status_code=401, detail="Not authenticated")
- try:
- payload = jwtauth.verify_token(token)
- sub = payload["sub"]
- email = payload["email"]
- return UserInfo(sub=sub, email=email)
- except JWTError as e:
- logger.error(f"JWT error: {e}")
- raise HTTPException(status_code=401, detail="Invalid authentication")
+async def _authenticate_user(
+ jwt_token: Optional[str],
+ api_key: Optional[str],
+ jwtauth: JWTAuth,
+) -> UserInfo | None:
+ user_infos: List[UserInfo] = []
+ if api_key:
+ user_api_key = await user_api_keys_controller.verify_key(api_key)
+ if user_api_key:
+ user_infos.append(UserInfo(sub=user_api_key.user_id, email=None))
+ if jwt_token:
+ try:
+ payload = jwtauth.verify_token(jwt_token)
+ sub = payload["sub"]
+ email = payload["email"]
+ user_infos.append(UserInfo(sub=sub, email=email))
+ except JWTError as e:
+ logger.error(f"JWT error: {e}")
+ raise HTTPException(status_code=401, detail="Invalid authentication")
-def current_user_optional(
- token: Annotated[Optional[str], Depends(oauth2_scheme)],
- jwtauth: JWTAuth = Depends(),
-):
- # we accept no token, but if one is provided, it must be a valid one.
- if token is None:
+ if len(user_infos) == 0:
return None
- try:
- payload = jwtauth.verify_token(token)
- sub = payload["sub"]
- email = payload["email"]
- return UserInfo(sub=sub, email=email)
- except JWTError as e:
- logger.error(f"JWT error: {e}")
- raise HTTPException(status_code=401, detail="Invalid authentication")
+
+ if len(set([x.sub for x in user_infos])) > 1:
+ raise JWTException(
+ status_code=401,
+ detail="Invalid authentication: more than one user provided",
+ )
+
+ return user_infos[0]
+
+
+async def current_user(
+ jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
+ api_key: Annotated[Optional[str], Depends(api_key_header)],
+ jwtauth: JWTAuth = Depends(),
+):
+ user = await _authenticate_user(jwt_token, api_key, jwtauth)
+ if user is None:
+ raise HTTPException(status_code=401, detail="Not authenticated")
+ return user
+
+
+async def current_user_optional(
+ jwt_token: Annotated[Optional[str], Depends(oauth2_scheme)],
+ api_key: Annotated[Optional[str], Depends(api_key_header)],
+ jwtauth: JWTAuth = Depends(),
+):
+ return await _authenticate_user(jwt_token, api_key, jwtauth)
diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py
index f79a2573..8822e6b0 100644
--- a/server/reflector/db/__init__.py
+++ b/server/reflector/db/__init__.py
@@ -29,6 +29,7 @@ import reflector.db.meetings # noqa
import reflector.db.recordings # noqa
import reflector.db.rooms # noqa
import reflector.db.transcripts # noqa
+import reflector.db.user_api_keys # noqa
kwargs = {}
if "postgres" not in settings.DATABASE_URL:
diff --git a/server/reflector/db/user_api_keys.py b/server/reflector/db/user_api_keys.py
new file mode 100644
index 00000000..b4fe7538
--- /dev/null
+++ b/server/reflector/db/user_api_keys.py
@@ -0,0 +1,90 @@
+import hmac
+import secrets
+from datetime import datetime, timezone
+from hashlib import sha256
+
+import sqlalchemy
+from pydantic import BaseModel, Field
+
+from reflector.db import get_database, metadata
+from reflector.settings import settings
+from reflector.utils import generate_uuid4
+from reflector.utils.string import NonEmptyString
+
+user_api_keys = sqlalchemy.Table(
+ "user_api_key",
+ metadata,
+ sqlalchemy.Column("id", sqlalchemy.String, primary_key=True),
+ sqlalchemy.Column("user_id", sqlalchemy.String, nullable=False),
+ sqlalchemy.Column("key_hash", sqlalchemy.String, nullable=False),
+ sqlalchemy.Column("name", sqlalchemy.String, nullable=True),
+ sqlalchemy.Column("created_at", sqlalchemy.DateTime(timezone=True), nullable=False),
+ sqlalchemy.Index("idx_user_api_key_hash", "key_hash", unique=True),
+ sqlalchemy.Index("idx_user_api_key_user_id", "user_id"),
+)
+
+
+class UserApiKey(BaseModel):
+ id: NonEmptyString = Field(default_factory=generate_uuid4)
+ user_id: NonEmptyString
+ key_hash: NonEmptyString
+ name: NonEmptyString | None = None
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+
+
+class UserApiKeyController:
+ @staticmethod
+ def generate_key() -> NonEmptyString:
+ return secrets.token_urlsafe(48)
+
+ @staticmethod
+ def hash_key(key: NonEmptyString) -> str:
+ return hmac.new(
+ settings.SECRET_KEY.encode(), key.encode(), digestmod=sha256
+ ).hexdigest()
+
+ @classmethod
+ async def create_key(
+ cls,
+ user_id: NonEmptyString,
+ name: NonEmptyString | None = None,
+ ) -> tuple[UserApiKey, NonEmptyString]:
+ plaintext = cls.generate_key()
+ api_key = UserApiKey(
+ user_id=user_id,
+ key_hash=cls.hash_key(plaintext),
+ name=name,
+ )
+ query = user_api_keys.insert().values(**api_key.model_dump())
+ await get_database().execute(query)
+ return api_key, plaintext
+
+ @classmethod
+ async def verify_key(cls, plaintext_key: NonEmptyString) -> UserApiKey | None:
+ key_hash = cls.hash_key(plaintext_key)
+ query = user_api_keys.select().where(
+ user_api_keys.c.key_hash == key_hash,
+ )
+ result = await get_database().fetch_one(query)
+ return UserApiKey(**result) if result else None
+
+ @staticmethod
+ async def list_by_user_id(user_id: NonEmptyString) -> list[UserApiKey]:
+ query = (
+ user_api_keys.select()
+ .where(user_api_keys.c.user_id == user_id)
+ .order_by(user_api_keys.c.created_at.desc())
+ )
+ results = await get_database().fetch_all(query)
+ return [UserApiKey(**r) for r in results]
+
+ @staticmethod
+ async def delete_key(key_id: NonEmptyString, user_id: NonEmptyString) -> bool:
+ query = user_api_keys.delete().where(
+ (user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id)
+ )
+ result = await get_database().execute(query)
+ return result > 0
+
+
+user_api_keys_controller = UserApiKeyController()
diff --git a/server/reflector/views/user_api_keys.py b/server/reflector/views/user_api_keys.py
new file mode 100644
index 00000000..f83768af
--- /dev/null
+++ b/server/reflector/views/user_api_keys.py
@@ -0,0 +1,62 @@
+from datetime import datetime
+from typing import Annotated
+
+import structlog
+from fastapi import APIRouter, Depends, HTTPException
+from pydantic import BaseModel
+
+import reflector.auth as auth
+from reflector.db.user_api_keys import user_api_keys_controller
+from reflector.utils.string import NonEmptyString
+
+router = APIRouter()
+logger = structlog.get_logger(__name__)
+
+
+class CreateApiKeyRequest(BaseModel):
+ name: NonEmptyString | None = None
+
+
+class ApiKeyResponse(BaseModel):
+ id: NonEmptyString
+ user_id: NonEmptyString
+ name: NonEmptyString | None
+ created_at: datetime
+
+
+class CreateApiKeyResponse(ApiKeyResponse):
+ key: NonEmptyString
+
+
+@router.post("/user/api-keys", response_model=CreateApiKeyResponse)
+async def create_api_key(
+ req: CreateApiKeyRequest,
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
+):
+ api_key_model, plaintext = await user_api_keys_controller.create_key(
+ user_id=user["sub"],
+ name=req.name,
+ )
+ return CreateApiKeyResponse(
+ **api_key_model.model_dump(),
+ key=plaintext,
+ )
+
+
+@router.get("/user/api-keys", response_model=list[ApiKeyResponse])
+async def list_api_keys(
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
+):
+ api_keys = await user_api_keys_controller.list_by_user_id(user["sub"])
+ return [ApiKeyResponse(**k.model_dump()) for k in api_keys]
+
+
+@router.delete("/user/api-keys/{key_id}")
+async def delete_api_key(
+ key_id: NonEmptyString,
+ user: Annotated[auth.UserInfo, Depends(auth.current_user)],
+):
+ deleted = await user_api_keys_controller.delete_key(key_id, user["sub"])
+ if not deleted:
+ raise HTTPException(status_code=404)
+ return {"status": "ok"}
diff --git a/server/tests/test_user_api_keys.py b/server/tests/test_user_api_keys.py
new file mode 100644
index 00000000..b92466b7
--- /dev/null
+++ b/server/tests/test_user_api_keys.py
@@ -0,0 +1,70 @@
+import pytest
+
+from reflector.db.user_api_keys import user_api_keys_controller
+
+
+@pytest.mark.asyncio
+async def test_api_key_creation_and_verification():
+ api_key_model, plaintext = await user_api_keys_controller.create_key(
+ user_id="test_user",
+ name="Test API Key",
+ )
+
+ verified = await user_api_keys_controller.verify_key(plaintext)
+ assert verified is not None
+ assert verified.user_id == "test_user"
+ assert verified.name == "Test API Key"
+
+ invalid = await user_api_keys_controller.verify_key("fake_key")
+ assert invalid is None
+
+
+@pytest.mark.asyncio
+async def test_api_key_hashing():
+ _, plaintext = await user_api_keys_controller.create_key(
+ user_id="test_user_2",
+ )
+
+ api_keys = await user_api_keys_controller.list_by_user_id("test_user_2")
+ assert len(api_keys) == 1
+ assert api_keys[0].key_hash != plaintext
+
+
+@pytest.mark.asyncio
+async def test_generate_api_key_uniqueness():
+ key1 = user_api_keys_controller.generate_key()
+ key2 = user_api_keys_controller.generate_key()
+ assert key1 != key2
+
+
+@pytest.mark.asyncio
+async def test_hash_api_key_deterministic():
+ key = "test_key_123"
+ hash1 = user_api_keys_controller.hash_key(key)
+ hash2 = user_api_keys_controller.hash_key(key)
+ assert hash1 == hash2
+
+
+@pytest.mark.asyncio
+async def test_get_by_user_id_empty():
+ api_keys = await user_api_keys_controller.list_by_user_id("nonexistent_user")
+ assert api_keys == []
+
+
+@pytest.mark.asyncio
+async def test_get_by_user_id_multiple():
+ user_id = "multi_key_user"
+
+ _, plaintext1 = await user_api_keys_controller.create_key(
+ user_id=user_id,
+ name="API Key 1",
+ )
+ _, plaintext2 = await user_api_keys_controller.create_key(
+ user_id=user_id,
+ name="API Key 2",
+ )
+
+ api_keys = await user_api_keys_controller.list_by_user_id(user_id)
+ assert len(api_keys) == 2
+ names = {k.name for k in api_keys}
+ assert names == {"API Key 1", "API Key 2"}
diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts
index e1709d69..2e6a775b 100644
--- a/www/app/reflector-api.d.ts
+++ b/www/app/reflector-api.d.ts
@@ -4,6 +4,23 @@
*/
export interface paths {
+ "/health": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Health */
+ get: operations["health"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/metrics": {
parameters: {
query?: never;
@@ -587,6 +604,41 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/v1/user/tokens": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** List Tokens */
+ get: operations["v1_list_tokens"];
+ put?: never;
+ /** Create Token */
+ post: operations["v1_create_token"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/v1/user/tokens/{token_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /** Delete Token */
+ delete: operations["v1_delete_token"];
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/v1/zulip/streams": {
parameters: {
query?: never;
@@ -759,6 +811,27 @@ export interface components {
*/
allow_duplicated: boolean | null;
};
+ /** CreateTokenRequest */
+ CreateTokenRequest: {
+ /** Name */
+ name?: string | null;
+ };
+ /** CreateTokenResponse */
+ CreateTokenResponse: {
+ /** Id */
+ id: string;
+ /** User Id */
+ user_id: string;
+ /** Name */
+ name: string | null;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /** Token */
+ token: string;
+ };
/** CreateTranscript */
CreateTranscript: {
/** Name */
@@ -1352,6 +1425,20 @@ export interface components {
* @enum {string}
*/
SyncStatus: "success" | "unchanged" | "error" | "skipped";
+ /** TokenResponse */
+ TokenResponse: {
+ /** Id */
+ id: string;
+ /** User Id */
+ user_id: string;
+ /** Name */
+ name: string | null;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ };
/** Topic */
Topic: {
/** Name */
@@ -1509,6 +1596,26 @@ export interface components {
}
export type $defs = Record;
export interface operations {
+ health: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ };
+ };
metrics: {
parameters: {
query?: never;
@@ -2899,6 +3006,90 @@ export interface operations {
};
};
};
+ v1_list_tokens: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TokenResponse"][];
+ };
+ };
+ };
+ };
+ v1_create_token: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["CreateTokenRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["CreateTokenResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ v1_delete_token: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ token_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
v1_zulip_get_streams: {
parameters: {
query?: never;
From c086b914453641d7fa9526fb66dd4129fe549f36 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Tue, 21 Oct 2025 08:30:22 -0600
Subject: [PATCH 58/77] chore(main): release 0.15.0 (#706)
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fab04f86..e0187e08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.15.0](https://github.com/Monadical-SAS/reflector/compare/v0.14.0...v0.15.0) (2025-10-20)
+
+
+### Features
+
+* api tokens ([#705](https://github.com/Monadical-SAS/reflector/issues/705)) ([9a258ab](https://github.com/Monadical-SAS/reflector/commit/9a258abc0209b0ac3799532a507ea6a9125d703a))
+
## [0.14.0](https://github.com/Monadical-SAS/reflector/compare/v0.13.1...v0.14.0) (2025-10-08)
From c6c035aacfb6ac67f9e5796c846fb6dfad1ab21a Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Tue, 21 Oct 2025 14:49:33 -0400
Subject: [PATCH 59/77] removal of email-verified from /me (#707)
Co-authored-by: Igor Loskutov
---
server/reflector/views/user.py | 1 -
www/app/reflector-api.d.ts | 2 --
2 files changed, 3 deletions(-)
diff --git a/server/reflector/views/user.py b/server/reflector/views/user.py
index fa68f79c..e62f43f7 100644
--- a/server/reflector/views/user.py
+++ b/server/reflector/views/user.py
@@ -11,7 +11,6 @@ router = APIRouter()
class UserInfo(BaseModel):
sub: str
email: Optional[str]
- email_verified: Optional[bool]
@router.get("/me")
diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts
index 2e6a775b..1e0a3819 100644
--- a/www/app/reflector-api.d.ts
+++ b/www/app/reflector-api.d.ts
@@ -1518,8 +1518,6 @@ export interface components {
sub: string;
/** Email */
email: string | null;
- /** Email Verified */
- email_verified: boolean | null;
};
/** ValidationError */
ValidationError: {
From 3c4b9f2103e050a9d435276e8cadd8598c2a0dd6 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Wed, 22 Oct 2025 13:45:08 -0400
Subject: [PATCH 60/77] chore: error reporting and naming (#708)
* chore: error reporting and naming
* chore: error reporting and naming
---------
Co-authored-by: Igor Loskutov
---
.github/workflows/deploy.yml | 2 +-
server/reflector/pipelines/main_file_pipeline.py | 7 ++++++-
server/reflector/processors/file_transcript_modal.py | 10 ++++++++++
.../processors/transcript_topic_detector.py | 12 ++++++++++--
4 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 16e84df6..fe33dd84 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,4 +1,4 @@
-name: Deploy to Amazon ECS
+name: Build container/push to container registry
on: [workflow_dispatch]
diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py
index bbf23e7b..0a05d593 100644
--- a/server/reflector/pipelines/main_file_pipeline.py
+++ b/server/reflector/pipelines/main_file_pipeline.py
@@ -426,7 +426,12 @@ async def task_pipeline_file_process(*, transcript_id: str):
await pipeline.process(audio_file)
- except Exception:
+ except Exception as e:
+ logger.error(
+ f"File pipeline failed for transcript {transcript_id}: {type(e).__name__}: {str(e)}",
+ exc_info=True,
+ transcript_id=transcript_id,
+ )
await pipeline.set_status(transcript_id, "error")
raise
diff --git a/server/reflector/processors/file_transcript_modal.py b/server/reflector/processors/file_transcript_modal.py
index 82250b6c..d29b8eac 100644
--- a/server/reflector/processors/file_transcript_modal.py
+++ b/server/reflector/processors/file_transcript_modal.py
@@ -56,6 +56,16 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
},
follow_redirects=True,
)
+
+ if response.status_code != 200:
+ error_body = response.text
+ self.logger.error(
+ "Modal API error",
+ audio_url=data.audio_url,
+ status_code=response.status_code,
+ error_body=error_body,
+ )
+
response.raise_for_status()
result = response.json()
diff --git a/server/reflector/processors/transcript_topic_detector.py b/server/reflector/processors/transcript_topic_detector.py
index e0e306ce..317e2d9c 100644
--- a/server/reflector/processors/transcript_topic_detector.py
+++ b/server/reflector/processors/transcript_topic_detector.py
@@ -34,8 +34,16 @@ TOPIC_PROMPT = dedent(
class TopicResponse(BaseModel):
"""Structured response for topic detection"""
- title: str = Field(description="A descriptive title for the topic being discussed")
- summary: str = Field(description="A concise 1-2 sentence summary of the discussion")
+ title: str = Field(
+ description="A descriptive title for the topic being discussed",
+ validation_alias="Title",
+ )
+ summary: str = Field(
+ description="A concise 1-2 sentence summary of the discussion",
+ validation_alias="Summary",
+ )
+
+ model_config = {"populate_by_name": True}
class TranscriptTopicDetectorProcessor(Processor):
From 962c40e2b6428ac42fd10aea926782d7a6f3f902 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Thu, 23 Oct 2025 20:16:43 -0400
Subject: [PATCH 61/77] feat: search date filter (#710)
* search date filter
* search date filter
* search date filter
* search date filter
* pr comment
---------
Co-authored-by: Igor Loskutov
---
server/reflector/db/search.py | 10 +
server/reflector/views/transcripts.py | 30 ++-
server/tests/test_search_date_filtering.py | 256 +++++++++++++++++++++
www/app/reflector-api.d.ts | 119 ++++++----
4 files changed, 361 insertions(+), 54 deletions(-)
create mode 100644 server/tests/test_search_date_filtering.py
diff --git a/server/reflector/db/search.py b/server/reflector/db/search.py
index caa21c65..5d9bc507 100644
--- a/server/reflector/db/search.py
+++ b/server/reflector/db/search.py
@@ -135,6 +135,8 @@ class SearchParameters(BaseModel):
user_id: str | None = None
room_id: str | None = None
source_kind: SourceKind | None = None
+ from_datetime: datetime | None = None
+ to_datetime: datetime | None = None
class SearchResultDB(BaseModel):
@@ -402,6 +404,14 @@ class SearchController:
base_query = base_query.where(
transcripts.c.source_kind == params.source_kind
)
+ if params.from_datetime:
+ base_query = base_query.where(
+ transcripts.c.created_at >= params.from_datetime
+ )
+ if params.to_datetime:
+ base_query = base_query.where(
+ transcripts.c.created_at <= params.to_datetime
+ )
if params.query_text is not None:
order_by = sqlalchemy.desc(sqlalchemy.text("rank"))
diff --git a/server/reflector/views/transcripts.py b/server/reflector/views/transcripts.py
index 04d27e1a..37e806cb 100644
--- a/server/reflector/views/transcripts.py
+++ b/server/reflector/views/transcripts.py
@@ -5,7 +5,7 @@ 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 BaseModel, Field, constr, field_serializer
+from pydantic import AwareDatetime, BaseModel, Field, constr, field_serializer
import reflector.auth as auth
from reflector.db import get_database
@@ -133,6 +133,21 @@ SearchOffsetParam = Annotated[
SearchOffsetBase, Query(description="Number of results to skip")
]
+SearchFromDatetimeParam = Annotated[
+ AwareDatetime | None,
+ Query(
+ alias="from",
+ description="Filter transcripts created on or after this datetime (ISO 8601 with timezone)",
+ ),
+]
+SearchToDatetimeParam = Annotated[
+ AwareDatetime | None,
+ Query(
+ alias="to",
+ description="Filter transcripts created on or before this datetime (ISO 8601 with timezone)",
+ ),
+]
+
class SearchResponse(BaseModel):
results: list[SearchResult]
@@ -174,18 +189,23 @@ async def transcripts_search(
offset: SearchOffsetParam = 0,
room_id: Optional[str] = None,
source_kind: Optional[SourceKind] = None,
+ from_datetime: SearchFromDatetimeParam = None,
+ to_datetime: SearchToDatetimeParam = None,
user: Annotated[
Optional[auth.UserInfo], Depends(auth.current_user_optional)
] = None,
):
- """
- Full-text search across transcript titles and content.
- """
+ """Full-text search across transcript titles and content."""
if not user and not settings.PUBLIC_MODE:
raise HTTPException(status_code=401, detail="Not authenticated")
user_id = user["sub"] if user else None
+ if from_datetime and to_datetime and from_datetime > to_datetime:
+ raise HTTPException(
+ status_code=400, detail="'from' must be less than or equal to 'to'"
+ )
+
search_params = SearchParameters(
query_text=parse_search_query_param(q),
limit=limit,
@@ -193,6 +213,8 @@ async def transcripts_search(
user_id=user_id,
room_id=room_id,
source_kind=source_kind,
+ from_datetime=from_datetime,
+ to_datetime=to_datetime,
)
results, total = await search_controller.search_transcripts(search_params)
diff --git a/server/tests/test_search_date_filtering.py b/server/tests/test_search_date_filtering.py
new file mode 100644
index 00000000..58fd6446
--- /dev/null
+++ b/server/tests/test_search_date_filtering.py
@@ -0,0 +1,256 @@
+from datetime import datetime, timedelta, timezone
+
+import pytest
+
+from reflector.db import get_database
+from reflector.db.search import SearchParameters, search_controller
+from reflector.db.transcripts import SourceKind, transcripts
+
+
+@pytest.mark.asyncio
+class TestDateRangeIntegration:
+ async def setup_test_transcripts(self):
+ # Use a test user_id that will match in our search parameters
+ test_user_id = "test-user-123"
+
+ test_data = [
+ {
+ "id": "test-before-range",
+ "created_at": datetime(2024, 1, 15, tzinfo=timezone.utc),
+ "title": "Before Range Transcript",
+ "user_id": test_user_id,
+ },
+ {
+ "id": "test-start-boundary",
+ "created_at": datetime(2024, 6, 1, tzinfo=timezone.utc),
+ "title": "Start Boundary Transcript",
+ "user_id": test_user_id,
+ },
+ {
+ "id": "test-middle-range",
+ "created_at": datetime(2024, 6, 15, tzinfo=timezone.utc),
+ "title": "Middle Range Transcript",
+ "user_id": test_user_id,
+ },
+ {
+ "id": "test-end-boundary",
+ "created_at": datetime(2024, 6, 30, 23, 59, 59, tzinfo=timezone.utc),
+ "title": "End Boundary Transcript",
+ "user_id": test_user_id,
+ },
+ {
+ "id": "test-after-range",
+ "created_at": datetime(2024, 12, 31, tzinfo=timezone.utc),
+ "title": "After Range Transcript",
+ "user_id": test_user_id,
+ },
+ ]
+
+ for data in test_data:
+ full_data = {
+ "id": data["id"],
+ "name": data["id"],
+ "status": "ended",
+ "locked": False,
+ "duration": 60.0,
+ "created_at": data["created_at"],
+ "title": data["title"],
+ "short_summary": "Test summary",
+ "long_summary": "Test long summary",
+ "share_mode": "public",
+ "source_kind": SourceKind.FILE,
+ "audio_deleted": False,
+ "reviewed": False,
+ "user_id": data["user_id"],
+ }
+
+ await get_database().execute(transcripts.insert().values(**full_data))
+
+ return test_data
+
+ async def cleanup_test_transcripts(self, test_data):
+ """Clean up test transcripts."""
+ for data in test_data:
+ await get_database().execute(
+ transcripts.delete().where(transcripts.c.id == data["id"])
+ )
+
+ @pytest.mark.asyncio
+ async def test_filter_with_from_datetime_only(self):
+ """Test filtering with only from_datetime parameter."""
+ test_data = await self.setup_test_transcripts()
+ test_user_id = "test-user-123"
+
+ try:
+ params = SearchParameters(
+ query_text=None,
+ from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
+ to_datetime=None,
+ user_id=test_user_id,
+ )
+
+ results, total = await search_controller.search_transcripts(params)
+
+ # Should include: start_boundary, middle, end_boundary, after
+ result_ids = [r.id for r in results]
+ assert "test-before-range" not in result_ids
+ assert "test-start-boundary" in result_ids
+ assert "test-middle-range" in result_ids
+ assert "test-end-boundary" in result_ids
+ assert "test-after-range" in result_ids
+
+ finally:
+ await self.cleanup_test_transcripts(test_data)
+
+ @pytest.mark.asyncio
+ async def test_filter_with_to_datetime_only(self):
+ """Test filtering with only to_datetime parameter."""
+ test_data = await self.setup_test_transcripts()
+ test_user_id = "test-user-123"
+
+ try:
+ params = SearchParameters(
+ query_text=None,
+ from_datetime=None,
+ to_datetime=datetime(2024, 6, 30, tzinfo=timezone.utc),
+ user_id=test_user_id,
+ )
+
+ results, total = await search_controller.search_transcripts(params)
+
+ result_ids = [r.id for r in results]
+ assert "test-before-range" in result_ids
+ assert "test-start-boundary" in result_ids
+ assert "test-middle-range" in result_ids
+ assert "test-end-boundary" not in result_ids
+ assert "test-after-range" not in result_ids
+
+ finally:
+ await self.cleanup_test_transcripts(test_data)
+
+ @pytest.mark.asyncio
+ async def test_filter_with_both_datetimes(self):
+ test_data = await self.setup_test_transcripts()
+ test_user_id = "test-user-123"
+
+ try:
+ params = SearchParameters(
+ query_text=None,
+ from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
+ to_datetime=datetime(
+ 2024, 7, 1, tzinfo=timezone.utc
+ ), # Inclusive of 6/30
+ user_id=test_user_id,
+ )
+
+ results, total = await search_controller.search_transcripts(params)
+
+ result_ids = [r.id for r in results]
+ assert "test-before-range" not in result_ids
+ assert "test-start-boundary" in result_ids
+ assert "test-middle-range" in result_ids
+ assert "test-end-boundary" in result_ids
+ assert "test-after-range" not in result_ids
+
+ finally:
+ await self.cleanup_test_transcripts(test_data)
+
+ @pytest.mark.asyncio
+ async def test_date_filter_with_room_and_source_kind(self):
+ test_data = await self.setup_test_transcripts()
+ test_user_id = "test-user-123"
+
+ try:
+ params = SearchParameters(
+ query_text=None,
+ from_datetime=datetime(2024, 6, 1, tzinfo=timezone.utc),
+ to_datetime=datetime(2024, 7, 1, tzinfo=timezone.utc),
+ source_kind=SourceKind.FILE,
+ room_id=None,
+ user_id=test_user_id,
+ )
+
+ results, total = await search_controller.search_transcripts(params)
+
+ for result in results:
+ assert result.source_kind == SourceKind.FILE
+ assert result.created_at >= datetime(2024, 6, 1, tzinfo=timezone.utc)
+ assert result.created_at <= datetime(2024, 7, 1, tzinfo=timezone.utc)
+
+ finally:
+ await self.cleanup_test_transcripts(test_data)
+
+ @pytest.mark.asyncio
+ async def test_empty_results_for_future_dates(self):
+ test_data = await self.setup_test_transcripts()
+ test_user_id = "test-user-123"
+
+ try:
+ params = SearchParameters(
+ query_text=None,
+ from_datetime=datetime(2099, 1, 1, tzinfo=timezone.utc),
+ to_datetime=datetime(2099, 12, 31, tzinfo=timezone.utc),
+ user_id=test_user_id,
+ )
+
+ results, total = await search_controller.search_transcripts(params)
+
+ assert results == []
+ assert total == 0
+
+ finally:
+ await self.cleanup_test_transcripts(test_data)
+
+ @pytest.mark.asyncio
+ async def test_date_only_input_handling(self):
+ test_data = await self.setup_test_transcripts()
+ test_user_id = "test-user-123"
+
+ try:
+ # Pydantic will parse date-only strings to datetime at midnight
+ from_dt = datetime(2024, 6, 15, 0, 0, 0, tzinfo=timezone.utc)
+ to_dt = datetime(2024, 6, 16, 0, 0, 0, tzinfo=timezone.utc)
+
+ params = SearchParameters(
+ query_text=None,
+ from_datetime=from_dt,
+ to_datetime=to_dt,
+ user_id=test_user_id,
+ )
+
+ results, total = await search_controller.search_transcripts(params)
+
+ result_ids = [r.id for r in results]
+ assert "test-middle-range" in result_ids
+ assert "test-before-range" not in result_ids
+ assert "test-after-range" not in result_ids
+
+ finally:
+ await self.cleanup_test_transcripts(test_data)
+
+
+class TestDateValidationEdgeCases:
+ """Edge case tests for datetime validation."""
+
+ def test_timezone_aware_comparison(self):
+ """Test that timezone-aware comparisons work correctly."""
+ # PST time (UTC-8)
+ pst = timezone(timedelta(hours=-8))
+ pst_dt = datetime(2024, 6, 15, 8, 0, 0, tzinfo=pst)
+
+ # UTC time equivalent (8AM PST = 4PM UTC)
+ utc_dt = datetime(2024, 6, 15, 16, 0, 0, tzinfo=timezone.utc)
+
+ assert pst_dt == utc_dt
+
+ def test_mixed_timezone_input(self):
+ """Test handling mixed timezone inputs."""
+ pst = timezone(timedelta(hours=-8))
+ ist = timezone(timedelta(hours=5, minutes=30))
+
+ from_date = datetime(2024, 6, 15, 0, 0, 0, tzinfo=pst) # PST midnight
+ to_date = datetime(2024, 6, 15, 23, 59, 59, tzinfo=ist) # IST end of day
+
+ assert from_date.tzinfo is not None
+ assert to_date.tzinfo is not None
+ assert from_date < to_date
diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts
index 1e0a3819..1dc92f2b 100644
--- a/www/app/reflector-api.d.ts
+++ b/www/app/reflector-api.d.ts
@@ -604,25 +604,25 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/v1/user/tokens": {
+ "/v1/user/api-keys": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
- /** List Tokens */
- get: operations["v1_list_tokens"];
+ /** List Api Keys */
+ get: operations["v1_list_api_keys"];
put?: never;
- /** Create Token */
- post: operations["v1_create_token"];
+ /** Create Api Key */
+ post: operations["v1_create_api_key"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
- "/v1/user/tokens/{token_id}": {
+ "/v1/user/api-keys/{key_id}": {
parameters: {
query?: never;
header?: never;
@@ -632,8 +632,8 @@ export interface paths {
get?: never;
put?: never;
post?: never;
- /** Delete Token */
- delete: operations["v1_delete_token"];
+ /** Delete Api Key */
+ delete: operations["v1_delete_api_key"];
options?: never;
head?: never;
patch?: never;
@@ -700,6 +700,26 @@ export interface paths {
export type webhooks = Record;
export interface components {
schemas: {
+ /** ApiKeyResponse */
+ ApiKeyResponse: {
+ /**
+ * Id
+ * @description A non-empty string
+ */
+ id: string;
+ /**
+ * User Id
+ * @description A non-empty string
+ */
+ user_id: string;
+ /** Name */
+ name: string | null;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ };
/** AudioWaveform */
AudioWaveform: {
/** Data */
@@ -759,6 +779,36 @@ export interface components {
*/
updated_at: string;
};
+ /** CreateApiKeyRequest */
+ CreateApiKeyRequest: {
+ /** Name */
+ name?: string | null;
+ };
+ /** CreateApiKeyResponse */
+ CreateApiKeyResponse: {
+ /**
+ * Id
+ * @description A non-empty string
+ */
+ id: string;
+ /**
+ * User Id
+ * @description A non-empty string
+ */
+ user_id: string;
+ /** Name */
+ name: string | null;
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /**
+ * Key
+ * @description A non-empty string
+ */
+ key: string;
+ };
/** CreateParticipant */
CreateParticipant: {
/** Speaker */
@@ -811,27 +861,6 @@ export interface components {
*/
allow_duplicated: boolean | null;
};
- /** CreateTokenRequest */
- CreateTokenRequest: {
- /** Name */
- name?: string | null;
- };
- /** CreateTokenResponse */
- CreateTokenResponse: {
- /** Id */
- id: string;
- /** User Id */
- user_id: string;
- /** Name */
- name: string | null;
- /**
- * Created At
- * Format: date-time
- */
- created_at: string;
- /** Token */
- token: string;
- };
/** CreateTranscript */
CreateTranscript: {
/** Name */
@@ -1425,20 +1454,6 @@ export interface components {
* @enum {string}
*/
SyncStatus: "success" | "unchanged" | "error" | "skipped";
- /** TokenResponse */
- TokenResponse: {
- /** Id */
- id: string;
- /** User Id */
- user_id: string;
- /** Name */
- name: string | null;
- /**
- * Created At
- * Format: date-time
- */
- created_at: string;
- };
/** Topic */
Topic: {
/** Name */
@@ -2263,6 +2278,10 @@ export interface operations {
offset?: number;
room_id?: string | null;
source_kind?: components["schemas"]["SourceKind"] | null;
+ /** @description Filter transcripts created on or after this datetime (ISO 8601 with timezone) */
+ from?: string | null;
+ /** @description Filter transcripts created on or before this datetime (ISO 8601 with timezone) */
+ to?: string | null;
};
header?: never;
path?: never;
@@ -3004,7 +3023,7 @@ export interface operations {
};
};
};
- v1_list_tokens: {
+ v1_list_api_keys: {
parameters: {
query?: never;
header?: never;
@@ -3019,12 +3038,12 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["TokenResponse"][];
+ "application/json": components["schemas"]["ApiKeyResponse"][];
};
};
};
};
- v1_create_token: {
+ v1_create_api_key: {
parameters: {
query?: never;
header?: never;
@@ -3033,7 +3052,7 @@ export interface operations {
};
requestBody: {
content: {
- "application/json": components["schemas"]["CreateTokenRequest"];
+ "application/json": components["schemas"]["CreateApiKeyRequest"];
};
};
responses: {
@@ -3043,7 +3062,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
- "application/json": components["schemas"]["CreateTokenResponse"];
+ "application/json": components["schemas"]["CreateApiKeyResponse"];
};
};
/** @description Validation Error */
@@ -3057,12 +3076,12 @@ export interface operations {
};
};
};
- v1_delete_token: {
+ v1_delete_api_key: {
parameters: {
query?: never;
header?: never;
path: {
- token_id: string;
+ key_id: string;
};
cookie?: never;
};
From 0baff7abf7478c5ec96864e46d321a8500588897 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Fri, 24 Oct 2025 16:52:02 -0400
Subject: [PATCH 62/77] transcript ui copy button placement (#712)
Co-authored-by: Igor Loskutov
---
.../[transcriptId]/finalSummary.tsx | 31 +++++++------------
.../(app)/transcripts/[transcriptId]/page.tsx | 14 ++++++---
www/app/(app)/transcripts/shareAndPrivacy.tsx | 26 ++++++++--------
www/app/(app)/transcripts/shareCopy.tsx | 19 ++++++------
www/app/(app)/transcripts/shareZulip.tsx | 8 ++---
www/app/(app)/transcripts/transcriptTitle.tsx | 25 +++++++++++----
6 files changed, 67 insertions(+), 56 deletions(-)
diff --git a/www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx b/www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx
index b1f61d43..d7ba37dc 100644
--- a/www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/finalSummary.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useState } from "react";
import React from "react";
import Markdown from "react-markdown";
import "../../../styles/markdown.css";
@@ -16,17 +16,15 @@ import {
} from "@chakra-ui/react";
import { LuPen } from "react-icons/lu";
import { useError } from "../../../(errors)/errorContext";
-import ShareAndPrivacy from "../shareAndPrivacy";
type FinalSummaryProps = {
- transcriptResponse: GetTranscript;
- topicsResponse: GetTranscriptTopic[];
- onUpdate?: (newSummary) => void;
+ transcript: GetTranscript;
+ topics: GetTranscriptTopic[];
+ onUpdate: (newSummary: string) => void;
+ finalSummaryRef: React.Dispatch>;
};
export default function FinalSummary(props: FinalSummaryProps) {
- const finalSummaryRef = useRef(null);
-
const [isEditMode, setIsEditMode] = useState(false);
const [preEditSummary, setPreEditSummary] = useState("");
const [editedSummary, setEditedSummary] = useState("");
@@ -35,10 +33,10 @@ export default function FinalSummary(props: FinalSummaryProps) {
const updateTranscriptMutation = useTranscriptUpdate();
useEffect(() => {
- setEditedSummary(props.transcriptResponse?.long_summary || "");
- }, [props.transcriptResponse?.long_summary]);
+ setEditedSummary(props.transcript?.long_summary || "");
+ }, [props.transcript?.long_summary]);
- if (!props.topicsResponse || !props.transcriptResponse) {
+ if (!props.topics || !props.transcript) {
return null;
}
@@ -54,9 +52,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
long_summary: newSummary,
},
});
- if (props.onUpdate) {
- props.onUpdate(newSummary);
- }
+ props.onUpdate(newSummary);
console.log("Updated long summary:", updatedTranscript);
} catch (err) {
console.error("Failed to update long summary:", err);
@@ -75,7 +71,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
};
const onSaveClick = () => {
- updateSummary(editedSummary, props.transcriptResponse.id);
+ updateSummary(editedSummary, props.transcript.id);
setIsEditMode(false);
};
@@ -133,11 +129,6 @@ export default function FinalSummary(props: FinalSummaryProps) {
>
-
>
)}
@@ -153,7 +144,7 @@ export default function FinalSummary(props: FinalSummaryProps) {
mt={2}
/>
) : (
-
+
{editedSummary}
)}
diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx
index f06e8935..ec5f9ebb 100644
--- a/www/app/(app)/transcripts/[transcriptId]/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx
@@ -41,6 +41,8 @@ export default function TranscriptDetails(details: TranscriptDetails) {
waiting || mp3.audioDeleted === true,
);
const useActiveTopic = useState
(null);
+ const [finalSummaryElement, setFinalSummaryElement] =
+ useState(null);
useEffect(() => {
if (waiting) {
@@ -124,9 +126,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
{
+ onUpdate={() => {
transcript.refetch().then(() => {});
}}
+ transcript={transcript.data || null}
+ topics={topics.topics}
+ finalSummaryElement={finalSummaryElement}
/>
{mp3.audioDeleted && (
@@ -148,11 +153,12 @@ export default function TranscriptDetails(details: TranscriptDetails) {
{transcript.data && topics.topics ? (
<>
{
- transcript.refetch();
+ transcript.refetch().then(() => {});
}}
+ finalSummaryRef={setFinalSummaryElement}
/>
>
) : (
diff --git a/www/app/(app)/transcripts/shareAndPrivacy.tsx b/www/app/(app)/transcripts/shareAndPrivacy.tsx
index 8580015d..04cda920 100644
--- a/www/app/(app)/transcripts/shareAndPrivacy.tsx
+++ b/www/app/(app)/transcripts/shareAndPrivacy.tsx
@@ -26,9 +26,9 @@ import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features";
type ShareAndPrivacyProps = {
- finalSummaryRef: any;
- transcriptResponse: GetTranscript;
- topicsResponse: GetTranscriptTopic[];
+ finalSummaryElement: HTMLDivElement | null;
+ transcript: GetTranscript;
+ topics: GetTranscriptTopic[];
};
type ShareOption = { value: ShareMode; label: string };
@@ -48,7 +48,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const [isOwner, setIsOwner] = useState(false);
const [shareMode, setShareMode] = useState(
shareOptionsData.find(
- (option) => option.value === props.transcriptResponse.share_mode,
+ (option) => option.value === props.transcript.share_mode,
) || shareOptionsData[0],
);
const [shareLoading, setShareLoading] = useState(false);
@@ -70,7 +70,7 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
try {
const updatedTranscript = await updateTranscriptMutation.mutateAsync({
params: {
- path: { transcript_id: props.transcriptResponse.id },
+ path: { transcript_id: props.transcript.id },
},
body: requestBody,
});
@@ -90,8 +90,8 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
const userId = auth.status === "authenticated" ? auth.user?.id : null;
useEffect(() => {
- setIsOwner(!!(requireLogin && userId === props.transcriptResponse.user_id));
- }, [userId, props.transcriptResponse.user_id]);
+ setIsOwner(!!(requireLogin && userId === props.transcript.user_id));
+ }, [userId, props.transcript.user_id]);
return (
<>
@@ -171,19 +171,19 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) {
{requireLogin && (
)}
-
+
diff --git a/www/app/(app)/transcripts/shareCopy.tsx b/www/app/(app)/transcripts/shareCopy.tsx
index dd56f213..fb1b5f68 100644
--- a/www/app/(app)/transcripts/shareCopy.tsx
+++ b/www/app/(app)/transcripts/shareCopy.tsx
@@ -5,34 +5,35 @@ type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { Button, BoxProps, Box } from "@chakra-ui/react";
type ShareCopyProps = {
- finalSummaryRef: any;
- transcriptResponse: GetTranscript;
- topicsResponse: GetTranscriptTopic[];
+ finalSummaryElement: HTMLDivElement | null;
+ transcript: GetTranscript;
+ topics: GetTranscriptTopic[];
};
export default function ShareCopy({
- finalSummaryRef,
- transcriptResponse,
- topicsResponse,
+ finalSummaryElement,
+ transcript,
+ topics,
...boxProps
}: ShareCopyProps & BoxProps) {
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
const onCopySummaryClick = () => {
- let text_to_copy = finalSummaryRef.current?.innerText;
+ const text_to_copy = finalSummaryElement?.innerText;
- text_to_copy &&
+ if (text_to_copy) {
navigator.clipboard.writeText(text_to_copy).then(() => {
setIsCopiedSummary(true);
// Reset the copied state after 2 seconds
setTimeout(() => setIsCopiedSummary(false), 2000);
});
+ }
};
const onCopyTranscriptClick = () => {
let text_to_copy =
- topicsResponse
+ topics
?.map((topic) => topic.transcript)
.join("\n\n")
.replace(/ +/g, " ")
diff --git a/www/app/(app)/transcripts/shareZulip.tsx b/www/app/(app)/transcripts/shareZulip.tsx
index bee14822..c3efe3ab 100644
--- a/www/app/(app)/transcripts/shareZulip.tsx
+++ b/www/app/(app)/transcripts/shareZulip.tsx
@@ -26,8 +26,8 @@ import {
import { featureEnabled } from "../../lib/features";
type ShareZulipProps = {
- transcriptResponse: GetTranscript;
- topicsResponse: GetTranscriptTopic[];
+ transcript: GetTranscript;
+ topics: GetTranscriptTopic[];
disabled: boolean;
};
@@ -88,14 +88,14 @@ export default function ShareZulip(props: ShareZulipProps & BoxProps) {
}, [stream, streams]);
const handleSendToZulip = async () => {
- if (!props.transcriptResponse) return;
+ if (!props.transcript) return;
if (stream && topic) {
try {
await postToZulipMutation.mutateAsync({
params: {
path: {
- transcript_id: props.transcriptResponse.id,
+ transcript_id: props.transcript.id,
},
query: {
stream,
diff --git a/www/app/(app)/transcripts/transcriptTitle.tsx b/www/app/(app)/transcripts/transcriptTitle.tsx
index 72421f48..1ac32b02 100644
--- a/www/app/(app)/transcripts/transcriptTitle.tsx
+++ b/www/app/(app)/transcripts/transcriptTitle.tsx
@@ -2,14 +2,22 @@ import { useState } from "react";
import type { components } from "../../reflector-api";
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
+type GetTranscript = components["schemas"]["GetTranscript"];
+type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { useTranscriptUpdate } from "../../lib/apiHooks";
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
import { LuPen } from "react-icons/lu";
+import ShareAndPrivacy from "./shareAndPrivacy";
type TranscriptTitle = {
title: string;
transcriptId: string;
- onUpdate?: (newTitle: string) => void;
+ onUpdate: (newTitle: string) => void;
+
+ // share props
+ transcript: GetTranscript | null;
+ topics: GetTranscriptTopic[] | null;
+ finalSummaryElement: HTMLDivElement | null;
};
const TranscriptTitle = (props: TranscriptTitle) => {
@@ -29,9 +37,7 @@ const TranscriptTitle = (props: TranscriptTitle) => {
},
body: requestBody,
});
- if (props.onUpdate) {
- props.onUpdate(newTitle);
- }
+ props.onUpdate(newTitle);
console.log("Updated transcript title:", newTitle);
} catch (err) {
console.error("Failed to update transcript:", err);
@@ -62,11 +68,11 @@ const TranscriptTitle = (props: TranscriptTitle) => {
}
setIsEditing(false);
};
- const handleChange = (e) => {
+ const handleChange = (e: React.ChangeEvent) => {
setDisplayedTitle(e.target.value);
};
- const handleKeyDown = (e) => {
+ const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
updateTitle(displayedTitle, props.transcriptId);
setIsEditing(false);
@@ -111,6 +117,13 @@ const TranscriptTitle = (props: TranscriptTitle) => {
>
+ {props.transcript && props.topics && (
+
+ )}
)}
>
From dc4b737daa7c0833349a47746d6b55d0e6c9276c Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Fri, 24 Oct 2025 16:18:49 -0600
Subject: [PATCH 63/77] chore(main): release 0.16.0 (#711)
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e0187e08..ce676740 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## [0.16.0](https://github.com/Monadical-SAS/reflector/compare/v0.15.0...v0.16.0) (2025-10-24)
+
+
+### Features
+
+* search date filter ([#710](https://github.com/Monadical-SAS/reflector/issues/710)) ([962c40e](https://github.com/Monadical-SAS/reflector/commit/962c40e2b6428ac42fd10aea926782d7a6f3f902))
+
## [0.15.0](https://github.com/Monadical-SAS/reflector/compare/v0.14.0...v0.15.0) (2025-10-20)
From d20aac66c4db925b52493658710acd5f18ac6a9c Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Mon, 10 Nov 2025 14:18:41 -0500
Subject: [PATCH 64/77] ui search pagination 2+page re-search fix (#714)
Co-authored-by: Igor Loskutov
---
www/app/(app)/browse/page.tsx | 25 +++++++++++++++++++++----
1 file changed, 21 insertions(+), 4 deletions(-)
diff --git a/www/app/(app)/browse/page.tsx b/www/app/(app)/browse/page.tsx
index 8523650e..05d8d5da 100644
--- a/www/app/(app)/browse/page.tsx
+++ b/www/app/(app)/browse/page.tsx
@@ -1,5 +1,5 @@
"use client";
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useMemo } from "react";
import {
Flex,
Spinner,
@@ -235,15 +235,26 @@ export default function TranscriptBrowser() {
const pageSize = 20;
+ // must be json-able
+ const searchFilters = useMemo(
+ () => ({
+ q: urlSearchQuery,
+ extras: {
+ room_id: urlRoomId || undefined,
+ source_kind: urlSourceKind || undefined,
+ },
+ }),
+ [urlSearchQuery, urlRoomId, urlSourceKind],
+ );
+
const {
data: searchData,
isLoading: searchLoading,
refetch: reloadSearch,
- } = useTranscriptsSearch(urlSearchQuery, {
+ } = useTranscriptsSearch(searchFilters.q, {
limit: pageSize,
offset: paginationPageTo0Based(page) * pageSize,
- room_id: urlRoomId || undefined,
- source_kind: urlSourceKind || undefined,
+ ...searchFilters.extras,
});
const results = searchData?.results || [];
@@ -255,6 +266,12 @@ export default function TranscriptBrowser() {
const totalPages = getTotalPages(totalResults, pageSize);
+ // reset pagination when search results change (detected by total change; good enough approximation)
+ useEffect(() => {
+ // operation is idempotent
+ setPage(FIRST_PAGE).then(() => {});
+ }, [JSON.stringify(searchFilters)]);
+
const userName = useUserName();
const [deletionLoading, setDeletionLoading] = useState(false);
const cancelRef = React.useRef(null);
From 372202b0e1a86823900b0aa77be1bfbc2893d8a1 Mon Sep 17 00:00:00 2001
From: Jose
Date: Mon, 10 Nov 2025 18:25:08 -0500
Subject: [PATCH 65/77] feat: add API key management UI (#716)
* feat: add API key management UI
- Created settings page for users to create, view, and delete API keys
- Added Settings link to app navigation header
- Fixed delete operation return value handling in backend to properly handle asyncpg's None response
* feat: replace browser confirm with dialog for API key deletion
- Added Chakra UI Dialog component for better UX when confirming API key deletion
- Implemented proper focus management with cancelRef for accessibility
- Replaced native browser confirm() with controlled dialog state
* style: format API keys page with consistent line breaks
* feat: auto-select API key text for easier copying
- Added automatic text selection after API key creation to streamline the copy workflow
- Applied className to Code component for DOM targeting
* feat: improve API keys page layout and responsiveness
- Reduced max width from 1200px to 800px for better readability
- Added explicit width constraint to ensure consistent sizing across viewports
* refactor: remove redundant comments from API keys page
---
server/reflector/db/user_api_keys.py | 3 +-
www/app/(app)/layout.tsx | 8 +
www/app/(app)/settings/api-keys/page.tsx | 341 +++++++++++++++++++++++
3 files changed, 351 insertions(+), 1 deletion(-)
create mode 100644 www/app/(app)/settings/api-keys/page.tsx
diff --git a/server/reflector/db/user_api_keys.py b/server/reflector/db/user_api_keys.py
index b4fe7538..8e0ab928 100644
--- a/server/reflector/db/user_api_keys.py
+++ b/server/reflector/db/user_api_keys.py
@@ -84,7 +84,8 @@ class UserApiKeyController:
(user_api_keys.c.id == key_id) & (user_api_keys.c.user_id == user_id)
)
result = await get_database().execute(query)
- return result > 0
+ # asyncpg returns None for DELETE, consider it success if no exception
+ return result is None or result > 0
user_api_keys_controller = UserApiKeyController()
diff --git a/www/app/(app)/layout.tsx b/www/app/(app)/layout.tsx
index 8bca1df6..7d9f1c84 100644
--- a/www/app/(app)/layout.tsx
+++ b/www/app/(app)/layout.tsx
@@ -78,6 +78,14 @@ export default async function AppLayout({
)}
{featureEnabled("requireLogin") ? (
<>
+ ·
+
+ Settings
+
·
>
diff --git a/www/app/(app)/settings/api-keys/page.tsx b/www/app/(app)/settings/api-keys/page.tsx
new file mode 100644
index 00000000..e63ed07a
--- /dev/null
+++ b/www/app/(app)/settings/api-keys/page.tsx
@@ -0,0 +1,341 @@
+"use client";
+import React, { useState, useRef } from "react";
+import {
+ Box,
+ Button,
+ Heading,
+ Stack,
+ Text,
+ Input,
+ Table,
+ Flex,
+ IconButton,
+ Code,
+ Dialog,
+} from "@chakra-ui/react";
+import { LuTrash2, LuCopy, LuPlus } from "react-icons/lu";
+import { useQueryClient } from "@tanstack/react-query";
+import { $api } from "../../../lib/apiClient";
+import { toaster } from "../../../components/ui/toaster";
+
+interface CreateApiKeyResponse {
+ id: string;
+ user_id: string;
+ name: string | null;
+ created_at: string;
+ key: string;
+}
+
+export default function ApiKeysPage() {
+ const [newKeyName, setNewKeyName] = useState("");
+ const [isCreating, setIsCreating] = useState(false);
+ const [createdKey, setCreatedKey] = useState(
+ null,
+ );
+ const [keyToDelete, setKeyToDelete] = useState(null);
+ const queryClient = useQueryClient();
+ const cancelRef = useRef(null);
+
+ const { data: apiKeys, isLoading } = $api.useQuery(
+ "get",
+ "/v1/user/api-keys",
+ );
+
+ const createKeyMutation = $api.useMutation("post", "/v1/user/api-keys", {
+ onSuccess: (data) => {
+ setCreatedKey(data);
+ setNewKeyName("");
+ setIsCreating(false);
+ queryClient.invalidateQueries({ queryKey: ["get", "/v1/user/api-keys"] });
+ toaster.create({
+ duration: 5000,
+ render: () => (
+
+ API key created
+
+ Make sure to copy it now - you won't see it again!
+
+
+ ),
+ });
+
+ setTimeout(() => {
+ const keyElement = document.querySelector(".api-key-code");
+ if (keyElement) {
+ const range = document.createRange();
+ range.selectNodeContents(keyElement);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }
+ }, 100);
+ },
+ onError: () => {
+ toaster.create({
+ duration: 3000,
+ render: () => (
+
+ Error
+ Failed to create API key
+
+ ),
+ });
+ },
+ });
+
+ const deleteKeyMutation = $api.useMutation(
+ "delete",
+ "/v1/user/api-keys/{key_id}",
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: ["get", "/v1/user/api-keys"],
+ });
+ toaster.create({
+ duration: 3000,
+ render: () => (
+
+ API key deleted
+
+ ),
+ });
+ },
+ onError: () => {
+ toaster.create({
+ duration: 3000,
+ render: () => (
+
+ Error
+ Failed to delete API key
+
+ ),
+ });
+ },
+ },
+ );
+
+ const handleCreateKey = () => {
+ createKeyMutation.mutate({
+ body: { name: newKeyName || null },
+ });
+ };
+
+ const handleCopyKey = (key: string) => {
+ navigator.clipboard.writeText(key);
+ toaster.create({
+ duration: 2000,
+ render: () => (
+
+ Copied to clipboard
+
+ ),
+ });
+ };
+
+ const handleDeleteRequest = (keyId: string) => {
+ setKeyToDelete(keyId);
+ };
+
+ const confirmDelete = () => {
+ if (keyToDelete) {
+ deleteKeyMutation.mutate({
+ params: { path: { key_id: keyToDelete } },
+ });
+ setKeyToDelete(null);
+ }
+ };
+
+ const formatDate = (dateString: string) => {
+ return new Date(dateString).toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ };
+
+ return (
+
+ API Keys
+
+ Manage your API keys for programmatic access to Reflector
+
+
+ {/* Show newly created key */}
+ {createdKey && (
+
+
+
+ API Key Created
+
+ setCreatedKey(null)}
+ >
+ ×
+
+
+
+ Make sure to copy your API key now. You won't be able to see it
+ again!
+
+
+
+ {createdKey.key}
+
+ handleCopyKey(createdKey.key)}
+ >
+
+
+
+
+ )}
+
+ {/* Create new key */}
+
+
+ Create New API Key
+
+ {!isCreating ? (
+ setIsCreating(true)} colorPalette="blue">
+ Create API Key
+
+ ) : (
+
+
+ Name (optional)
+ setNewKeyName(e.target.value)}
+ />
+
+
+
+ Create
+
+ {
+ setIsCreating(false);
+ setNewKeyName("");
+ }}
+ variant="outline"
+ >
+ Cancel
+
+
+
+ )}
+
+
+ {/* List of API keys */}
+
+
+ Your API Keys
+
+ {isLoading ? (
+ Loading...
+ ) : !apiKeys || apiKeys.length === 0 ? (
+
+ No API keys yet. Create one to get started.
+
+ ) : (
+
+
+
+ Name
+ Created
+ Actions
+
+
+
+ {apiKeys.map((key) => (
+
+
+ {key.name || Unnamed}
+
+ {formatDate(key.created_at)}
+
+ handleDeleteRequest(key.id)}
+ loading={
+ deleteKeyMutation.isPending &&
+ deleteKeyMutation.variables?.params?.path?.key_id ===
+ key.id
+ }
+ >
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ {/* Delete confirmation dialog */}
+ {
+ if (!e.open) setKeyToDelete(null);
+ }}
+ initialFocusEl={() => cancelRef.current}
+ >
+
+
+
+
+ Delete API Key
+
+
+
+ Are you sure you want to delete this API key? This action cannot
+ be undone.
+
+
+
+ setKeyToDelete(null)}
+ variant="outline"
+ colorPalette="gray"
+ >
+ Cancel
+
+
+ Delete
+
+
+
+
+
+
+ );
+}
From 1473fd82dc472c394cbaa2987212ad662a74bcac Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Wed, 12 Nov 2025 21:21:16 -0500
Subject: [PATCH 66/77] feat: daily.co support as alternative to whereby (#691)
* llm instructions
* vibe dailyco
* vibe dailyco
* doc update (vibe)
* dont show recording ui on call
* stub processor (vibe)
* stub processor (vibe) self-review
* stub processor (vibe) self-review
* chore(main): release 0.14.0 (#670)
* Add multitrack pipeline
* Mixdown audio tracks
* Mixdown with pyav filter graph
* Trigger multitrack processing for daily recordings
* apply platform from envs in priority: non-dry
* Use explicit track keys for processing
* Align tracks of a multitrack recording
* Generate waveforms for the mixed audio
* Emit multriack pipeline events
* Fix multitrack pipeline track alignment
* dailico docs
* Enable multitrack reprocessing
* modal temp files uniform names, cleanup. remove llm temporary docs
* docs cleanup
* dont proceed with raw recordings if any of the downloads fail
* dry transcription pipelines
* remove is_miltitrack
* comments
* explicit dailyco room name
* docs
* remove stub data/method
* frontend daily/whereby code self-review (no-mistake)
* frontend daily/whereby code self-review (no-mistakes)
* frontend daily/whereby code self-review (no-mistakes)
* consent cleanup for multitrack (no-mistakes)
* llm fun
* remove extra comments
* fix tests
* merge migrations
* Store participant names
* Get participants by meeting session id
* pop back main branch migration
* s3 paddington (no-mistakes)
* comment
* pr comments
* pr comments
* pr comments
* platform / meeting cleanup
* Use participant names in summary generation
* platform assignment to meeting at controller level
* pr comment
* room playform properly default none
* room playform properly default none
* restore migration lost
* streaming WIP
* extract storage / use common storage / proper env vars for storage
* fix mocks tests
* remove fall back
* streaming for multifile
* cenrtal storage abstraction (no-mistakes)
* remove dead code / vars
* Set participant user id for authenticated users
* whereby recording name parsing fix
* whereby recording name parsing fix
* more file stream
* storage dry + tests
* remove homemade boto3 streaming and use proper boto
* update migration guide
* webhook creation script - print uuid
---------
Co-authored-by: Igor Loskutov
Co-authored-by: Mathieu Virbel
Co-authored-by: Sergey Mankovsky
---
server/docs/video-platforms/README.md | 234 ++++++
server/env.example | 27 +
.../1e49625677e4_add_platform_support.py | 50 ++
.../versions/f8294b31f022_add_track_keys.py | 28 +
server/reflector/app.py | 2 +
server/reflector/db/meetings.py | 57 +-
server/reflector/db/recordings.py | 4 +
server/reflector/db/rooms.py | 12 +-
server/reflector/db/transcripts.py | 19 +-
server/reflector/pipelines/__init__.py | 1 +
.../reflector/pipelines/main_file_pipeline.py | 115 +--
.../reflector/pipelines/main_live_pipeline.py | 59 +-
.../pipelines/main_multitrack_pipeline.py | 694 ++++++++++++++++++
.../reflector/pipelines/topic_processing.py | 109 +++
.../pipelines/transcription_helpers.py | 34 +
.../processors/summary/summary_builder.py | 69 +-
.../processors/transcript_final_summary.py | 38 +-
.../processors/transcript_topic_detector.py | 8 +-
server/reflector/schemas/platform.py | 5 +
server/reflector/settings.py | 31 +-
server/reflector/storage/__init__.py | 58 +-
server/reflector/storage/base.py | 130 +++-
server/reflector/storage/storage_aws.py | 229 +++++-
server/reflector/utils/daily.py | 26 +
server/reflector/utils/datetime.py | 9 +
server/reflector/utils/string.py | 11 +-
server/reflector/utils/url.py | 37 +
server/reflector/video_platforms/__init__.py | 11 +
server/reflector/video_platforms/base.py | 54 ++
server/reflector/video_platforms/daily.py | 198 +++++
server/reflector/video_platforms/factory.py | 62 ++
server/reflector/video_platforms/models.py | 40 +
server/reflector/video_platforms/registry.py | 35 +
server/reflector/video_platforms/whereby.py | 141 ++++
.../video_platforms/whereby_utils.py | 38 +
server/reflector/views/daily.py | 233 ++++++
server/reflector/views/rooms.py | 89 ++-
server/reflector/views/transcripts_process.py | 29 +-
server/reflector/whereby.py | 114 ---
server/reflector/worker/cleanup.py | 6 +-
server/reflector/worker/ics_sync.py | 34 +-
server/reflector/worker/process.py | 354 +++++++--
server/scripts/recreate_daily_webhook.py | 123 ++++
server/tests/conftest.py | 12 +
server/tests/mocks/__init__.py | 0
server/tests/mocks/mock_platform.py | 112 +++
server/tests/test_cleanup.py | 8 +-
server/tests/test_consent_multitrack.py | 330 +++++++++
server/tests/test_pipeline_main_file.py | 23 +-
server/tests/test_room_ics_api.py | 10 +
server/tests/test_storage.py | 321 ++++++++
.../test_transcripts_recording_deletion.py | 7 +-
server/tests/test_utils_daily.py | 17 +
server/tests/test_utils_url.py | 63 ++
server/tests/test_video_platforms_factory.py | 58 ++
www/app/[roomName]/[meetingId]/page.tsx | 4 +-
www/app/[roomName]/components/DailyRoom.tsx | 93 +++
.../[roomName]/components/RoomContainer.tsx | 214 ++++++
www/app/[roomName]/components/WherebyRoom.tsx | 101 +++
www/app/[roomName]/page.tsx | 4 +-
www/app/lib/consent/ConsentDialog.tsx | 36 +
www/app/lib/consent/ConsentDialogButton.tsx | 39 +
www/app/lib/consent/constants.ts | 12 +
www/app/lib/consent/index.ts | 8 +
www/app/lib/consent/types.ts | 9 +
www/app/lib/consent/useConsentDialog.tsx | 109 +++
www/app/lib/consent/utils.ts | 13 +
www/app/lib/useLoginRequiredPages.ts | 5 +-
www/app/reflector-api.d.ts | 91 +++
www/package.json | 1 +
www/pnpm-lock.yaml | 96 +++
71 files changed, 4985 insertions(+), 468 deletions(-)
create mode 100644 server/docs/video-platforms/README.md
create mode 100644 server/migrations/versions/1e49625677e4_add_platform_support.py
create mode 100644 server/migrations/versions/f8294b31f022_add_track_keys.py
create mode 100644 server/reflector/pipelines/__init__.py
create mode 100644 server/reflector/pipelines/main_multitrack_pipeline.py
create mode 100644 server/reflector/pipelines/topic_processing.py
create mode 100644 server/reflector/pipelines/transcription_helpers.py
create mode 100644 server/reflector/schemas/platform.py
create mode 100644 server/reflector/utils/daily.py
create mode 100644 server/reflector/utils/datetime.py
create mode 100644 server/reflector/utils/url.py
create mode 100644 server/reflector/video_platforms/__init__.py
create mode 100644 server/reflector/video_platforms/base.py
create mode 100644 server/reflector/video_platforms/daily.py
create mode 100644 server/reflector/video_platforms/factory.py
create mode 100644 server/reflector/video_platforms/models.py
create mode 100644 server/reflector/video_platforms/registry.py
create mode 100644 server/reflector/video_platforms/whereby.py
create mode 100644 server/reflector/video_platforms/whereby_utils.py
create mode 100644 server/reflector/views/daily.py
delete mode 100644 server/reflector/whereby.py
create mode 100644 server/scripts/recreate_daily_webhook.py
create mode 100644 server/tests/mocks/__init__.py
create mode 100644 server/tests/mocks/mock_platform.py
create mode 100644 server/tests/test_consent_multitrack.py
create mode 100644 server/tests/test_storage.py
create mode 100644 server/tests/test_utils_daily.py
create mode 100644 server/tests/test_utils_url.py
create mode 100644 server/tests/test_video_platforms_factory.py
create mode 100644 www/app/[roomName]/components/DailyRoom.tsx
create mode 100644 www/app/[roomName]/components/RoomContainer.tsx
create mode 100644 www/app/[roomName]/components/WherebyRoom.tsx
create mode 100644 www/app/lib/consent/ConsentDialog.tsx
create mode 100644 www/app/lib/consent/ConsentDialogButton.tsx
create mode 100644 www/app/lib/consent/constants.ts
create mode 100644 www/app/lib/consent/index.ts
create mode 100644 www/app/lib/consent/types.ts
create mode 100644 www/app/lib/consent/useConsentDialog.tsx
create mode 100644 www/app/lib/consent/utils.ts
diff --git a/server/docs/video-platforms/README.md b/server/docs/video-platforms/README.md
new file mode 100644
index 00000000..45a615c3
--- /dev/null
+++ b/server/docs/video-platforms/README.md
@@ -0,0 +1,234 @@
+# Reflector Architecture: Whereby + Daily.co Recording Storage
+
+## System Overview
+
+```mermaid
+graph TB
+ subgraph "Actors"
+ APP[Our App
Reflector]
+ WHEREBY[Whereby Service
External]
+ DAILY[Daily.co Service
External]
+ end
+
+ subgraph "AWS S3 Buckets"
+ TRANSCRIPT_BUCKET[Transcript Bucket
reflector-transcripts
Output: Processed MP3s]
+ WHEREBY_BUCKET[Whereby Bucket
reflector-whereby-recordings
Input: Raw MP4s]
+ DAILY_BUCKET[Daily.co Bucket
reflector-dailyco-recordings
Input: Raw WebM tracks]
+ end
+
+ subgraph "AWS Infrastructure"
+ SQS[SQS Queue
Whereby notifications]
+ end
+
+ subgraph "Database"
+ DB[(PostgreSQL
Recordings, Transcripts, Meetings)]
+ end
+
+ APP -->|Write processed| TRANSCRIPT_BUCKET
+ APP -->|Read/Delete| WHEREBY_BUCKET
+ APP -->|Read/Delete| DAILY_BUCKET
+ APP -->|Poll| SQS
+ APP -->|Store metadata| DB
+
+ WHEREBY -->|Write recordings| WHEREBY_BUCKET
+ WHEREBY_BUCKET -->|S3 Event| SQS
+ WHEREBY -->|Participant webhooks
room.client.joined/left| APP
+
+ DAILY -->|Write recordings| DAILY_BUCKET
+ DAILY -->|Recording webhook
recording.ready-to-download| APP
+```
+
+**Note on Webhook vs S3 Event for Recording Processing:**
+- **Whereby**: Uses S3 Events → SQS for recording availability (S3 as source of truth, no race conditions)
+- **Daily.co**: Uses webhooks for recording availability (more immediate, built-in reliability)
+- **Both**: Use webhooks for participant tracking (real-time updates)
+
+## Credentials & Permissions
+
+```mermaid
+graph LR
+ subgraph "Master Credentials"
+ MASTER[TRANSCRIPT_STORAGE_AWS_*
Access Key ID + Secret]
+ end
+
+ subgraph "Whereby Upload Credentials"
+ WHEREBY_CREDS[AWS_WHEREBY_ACCESS_KEY_*
Access Key ID + Secret]
+ end
+
+ subgraph "Daily.co Upload Role"
+ DAILY_ROLE[DAILY_STORAGE_AWS_ROLE_ARN
IAM Role ARN]
+ end
+
+ subgraph "Our App Uses"
+ MASTER -->|Read/Write/Delete| TRANSCRIPT_BUCKET[Transcript Bucket]
+ MASTER -->|Read/Delete| WHEREBY_BUCKET[Whereby Bucket]
+ MASTER -->|Read/Delete| DAILY_BUCKET[Daily.co Bucket]
+ MASTER -->|Poll/Delete| SQS[SQS Queue]
+ end
+
+ subgraph "We Give To Services"
+ WHEREBY_CREDS -->|Passed in API call| WHEREBY_SERVICE[Whereby Service]
+ WHEREBY_SERVICE -->|Write Only| WHEREBY_BUCKET
+
+ DAILY_ROLE -->|Passed in API call| DAILY_SERVICE[Daily.co Service]
+ DAILY_SERVICE -->|Assume Role| DAILY_ROLE
+ DAILY_SERVICE -->|Write Only| DAILY_BUCKET
+ end
+```
+
+# Video Platform Recording Integration
+
+This document explains how Reflector receives and identifies multitrack audio recordings from different video platforms.
+
+## Platform Comparison
+
+| Platform | Delivery Method | Track Identification |
+|----------|----------------|---------------------|
+| **Daily.co** | Webhook | Explicit track list in payload |
+| **Whereby** | SQS (S3 notifications) | Single file per notification |
+
+---
+
+## Daily.co (Webhook-based)
+
+Daily.co uses **webhooks** to notify Reflector when recordings are ready.
+
+### How It Works
+
+1. **Daily.co sends webhook** when recording is ready
+ - Event type: `recording.ready-to-download`
+ - Endpoint: `/v1/daily/webhook` (`reflector/views/daily.py:46-102`)
+
+2. **Webhook payload explicitly includes track list**:
+```json
+{
+ "recording_id": "7443ee0a-dab1-40eb-b316-33d6c0d5ff88",
+ "room_name": "daily-20251020193458",
+ "tracks": [
+ {
+ "type": "audio",
+ "s3Key": "monadical/daily-20251020193458/1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922",
+ "size": 831843
+ },
+ {
+ "type": "audio",
+ "s3Key": "monadical/daily-20251020193458/1760988935484-a37c35e3-6f8e-4274-a482-e9d0f102a732-cam-audio-1760988943823",
+ "size": 408438
+ },
+ {
+ "type": "video",
+ "s3Key": "monadical/daily-20251020193458/...-video.webm",
+ "size": 30000000
+ }
+ ]
+}
+```
+
+3. **System extracts audio tracks** (`daily.py:211`):
+```python
+track_keys = [t.s3Key for t in tracks if t.type == "audio"]
+```
+
+4. **Triggers multitrack processing** (`daily.py:213-218`):
+```python
+process_multitrack_recording.delay(
+ bucket_name=bucket_name, # reflector-dailyco-local
+ room_name=room_name, # daily-20251020193458
+ recording_id=recording_id, # 7443ee0a-dab1-40eb-b316-33d6c0d5ff88
+ track_keys=track_keys # Only audio s3Keys
+)
+```
+
+### Key Advantage: No Ambiguity
+
+Even though multiple meetings may share the same S3 bucket/folder (`monadical/`), **there's no ambiguity** because:
+- Each webhook payload contains the exact `s3Key` list for that specific `recording_id`
+- No need to scan folders or guess which files belong together
+- Each track's s3Key includes the room timestamp subfolder (e.g., `daily-20251020193458/`)
+
+The room name includes timestamp (`daily-20251020193458`) to keep recordings organized, but **the webhook's explicit track list is what prevents mixing files from different meetings**.
+
+### Track Timeline Extraction
+
+Daily.co provides timing information in two places:
+
+**1. PyAV WebM Metadata (current approach)**:
+```python
+# Read from WebM container stream metadata
+stream.start_time = 8.130s # Meeting-relative timing
+```
+
+**2. Filename Timestamps (alternative approach, commit 3bae9076)**:
+```
+Filename format: {recording_start_ts}-{uuid}-cam-audio-{track_start_ts}.webm
+Example: 1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922.webm
+
+Parse timestamps:
+- recording_start_ts: 1760988935484 (Unix ms)
+- track_start_ts: 1760988935922 (Unix ms)
+- offset: (1760988935922 - 1760988935484) / 1000 = 0.438s
+```
+
+**Time Difference (PyAV vs Filename)**:
+```
+Track 0:
+ Filename offset: 438ms
+ PyAV metadata: 229ms
+ Difference: 209ms
+
+Track 1:
+ Filename offset: 8339ms
+ PyAV metadata: 8130ms
+ Difference: 209ms
+```
+
+**Consistent 209ms delta** suggests network/encoding delay between file upload initiation (filename) and actual audio stream start (metadata).
+
+**Current implementation uses PyAV metadata** because:
+- More accurate (represents when audio actually started)
+- Padding BEFORE transcription produces correct Whisper timestamps automatically
+- No manual offset adjustment needed during transcript merge
+
+### Why Re-encoding During Padding
+
+Padding coincidentally involves re-encoding, which is important for Daily.co + Whisper:
+
+**Problem:** Daily.co skips frames in recordings when microphone is muted or paused
+- WebM containers have gaps where audio frames should be
+- Whisper doesn't understand these gaps and produces incorrect timestamps
+- Example: 5s of audio with 2s muted → file has frames only for 3s, Whisper thinks duration is 3s
+
+**Solution:** Re-encoding via PyAV filter graph (`adelay` + `aresample`)
+- Restores missing frames as silence
+- Produces continuous audio stream without gaps
+- Whisper now sees correct duration and produces accurate timestamps
+
+**Why combined with padding:**
+- Already re-encoding for padding (adding initial silence)
+- More performant to do both operations in single PyAV pipeline
+- Padded values needed for mixdown anyway (creating final MP3)
+
+Implementation: `main_multitrack_pipeline.py:_apply_audio_padding_streaming()`
+
+---
+
+## Whereby (SQS-based)
+
+Whereby uses **AWS SQS** (via S3 notifications) to notify Reflector when files are uploaded.
+
+### How It Works
+
+1. **Whereby uploads recording** to S3
+2. **S3 sends notification** to SQS queue (one notification per file)
+3. **Reflector polls SQS queue** (`worker/process.py:process_messages()`)
+4. **System processes single file** (`worker/process.py:process_recording()`)
+
+### Key Difference from Daily.co
+
+**Whereby (SQS):** System receives S3 notification "file X was created" - only knows about one file at a time, would need to scan folder to find related files
+
+**Daily.co (Webhook):** Daily explicitly tells system which files belong together in the webhook payload
+
+---
+
+
diff --git a/server/env.example b/server/env.example
index ff0f4211..7375bf0a 100644
--- a/server/env.example
+++ b/server/env.example
@@ -71,3 +71,30 @@ DIARIZATION_URL=https://monadical-sas--reflector-diarizer-web.modal.run
## Sentry DSN configuration
#SENTRY_DSN=
+
+## =======================================================
+## Video Platform Configuration
+## =======================================================
+
+## Whereby
+#WHEREBY_API_KEY=your-whereby-api-key
+#WHEREBY_WEBHOOK_SECRET=your-whereby-webhook-secret
+#WHEREBY_STORAGE_AWS_ACCESS_KEY_ID=your-aws-key
+#WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY=your-aws-secret
+#AWS_PROCESS_RECORDING_QUEUE_URL=https://sqs.us-west-2.amazonaws.com/...
+
+## Daily.co
+#DAILY_API_KEY=your-daily-api-key
+#DAILY_WEBHOOK_SECRET=your-daily-webhook-secret
+#DAILY_SUBDOMAIN=your-subdomain
+#DAILY_WEBHOOK_UUID= # Auto-populated by recreate_daily_webhook.py script
+#DAILYCO_STORAGE_AWS_ROLE_ARN=... # IAM role ARN for Daily.co S3 access
+#DAILYCO_STORAGE_AWS_BUCKET_NAME=reflector-dailyco
+#DAILYCO_STORAGE_AWS_REGION=us-west-2
+
+## Whereby (optional separate bucket)
+#WHEREBY_STORAGE_AWS_BUCKET_NAME=reflector-whereby
+#WHEREBY_STORAGE_AWS_REGION=us-east-1
+
+## Platform Configuration
+#DEFAULT_VIDEO_PLATFORM=whereby # Default platform for new rooms
diff --git a/server/migrations/versions/1e49625677e4_add_platform_support.py b/server/migrations/versions/1e49625677e4_add_platform_support.py
new file mode 100644
index 00000000..fa403f92
--- /dev/null
+++ b/server/migrations/versions/1e49625677e4_add_platform_support.py
@@ -0,0 +1,50 @@
+"""add_platform_support
+
+Revision ID: 1e49625677e4
+Revises: 9e3f7b2a4c8e
+Create Date: 2025-10-08 13:17:29.943612
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "1e49625677e4"
+down_revision: Union[str, None] = "9e3f7b2a4c8e"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Add platform field with default 'whereby' for backward compatibility."""
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column(
+ "platform",
+ sa.String(),
+ nullable=True,
+ server_default=None,
+ )
+ )
+
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column(
+ "platform",
+ sa.String(),
+ nullable=False,
+ server_default="whereby",
+ )
+ )
+
+
+def downgrade() -> None:
+ """Remove platform field."""
+ with op.batch_alter_table("meeting", schema=None) as batch_op:
+ batch_op.drop_column("platform")
+
+ with op.batch_alter_table("room", schema=None) as batch_op:
+ batch_op.drop_column("platform")
diff --git a/server/migrations/versions/f8294b31f022_add_track_keys.py b/server/migrations/versions/f8294b31f022_add_track_keys.py
new file mode 100644
index 00000000..7eda6ccc
--- /dev/null
+++ b/server/migrations/versions/f8294b31f022_add_track_keys.py
@@ -0,0 +1,28 @@
+"""add_track_keys
+
+Revision ID: f8294b31f022
+Revises: 1e49625677e4
+Create Date: 2025-10-27 18:52:17.589167
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "f8294b31f022"
+down_revision: Union[str, None] = "1e49625677e4"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("recording", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("track_keys", sa.JSON(), nullable=True))
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("recording", schema=None) as batch_op:
+ batch_op.drop_column("track_keys")
diff --git a/server/reflector/app.py b/server/reflector/app.py
index a15934f5..2ca76acb 100644
--- a/server/reflector/app.py
+++ b/server/reflector/app.py
@@ -12,6 +12,7 @@ from reflector.events import subscribers_shutdown, subscribers_startup
from reflector.logger import logger
from reflector.metrics import metrics_init
from reflector.settings import settings
+from reflector.views.daily import router as daily_router
from reflector.views.meetings import router as meetings_router
from reflector.views.rooms import router as rooms_router
from reflector.views.rtc_offer import router as rtc_offer_router
@@ -96,6 +97,7 @@ app.include_router(user_api_keys_router, prefix="/v1")
app.include_router(user_ws_router, prefix="/v1")
app.include_router(zulip_router, prefix="/v1")
app.include_router(whereby_router, prefix="/v1")
+app.include_router(daily_router, prefix="/v1/daily")
add_pagination(app)
# prepare celery
diff --git a/server/reflector/db/meetings.py b/server/reflector/db/meetings.py
index 12a0c187..6912b285 100644
--- a/server/reflector/db/meetings.py
+++ b/server/reflector/db/meetings.py
@@ -7,7 +7,10 @@ from sqlalchemy.dialects.postgresql import JSONB
from reflector.db import get_database, metadata
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",
@@ -55,6 +58,12 @@ meetings = sa.Table(
),
),
sa.Column("calendar_metadata", JSONB),
+ sa.Column(
+ "platform",
+ sa.String,
+ nullable=False,
+ server_default=assert_equal(WHEREBY_PLATFORM, "whereby"),
+ ),
sa.Index("idx_meeting_room_id", "room_id"),
sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
)
@@ -94,13 +103,14 @@ class Meeting(BaseModel):
is_locked: bool = False
room_mode: Literal["normal", "group"] = "normal"
recording_type: Literal["none", "local", "cloud"] = "cloud"
- recording_trigger: Literal[
+ recording_trigger: Literal[ # whereby-specific
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
num_clients: int = 0
is_active: bool = True
calendar_event_id: str | None = None
calendar_metadata: dict[str, Any] | None = None
+ platform: Platform = WHEREBY_PLATFORM
class MeetingController:
@@ -130,6 +140,7 @@ class MeetingController:
recording_trigger=room.recording_trigger,
calendar_event_id=calendar_event_id,
calendar_metadata=calendar_metadata,
+ platform=get_platform(room.platform),
)
query = meetings.insert().values(**meeting.model_dump())
await get_database().execute(query)
@@ -137,7 +148,8 @@ class MeetingController:
async def get_all_active(self) -> list[Meeting]:
query = meetings.select().where(meetings.c.is_active)
- return await get_database().fetch_all(query)
+ results = await get_database().fetch_all(query)
+ return [Meeting(**result) for result in results]
async def get_by_room_name(
self,
@@ -147,16 +159,14 @@ class MeetingController:
Get a meeting by room name.
For backward compatibility, returns the most recent meeting.
"""
- end_date = getattr(meetings.c, "end_date")
query = (
meetings.select()
.where(meetings.c.room_name == room_name)
- .order_by(end_date.desc())
+ .order_by(meetings.c.end_date.desc())
)
result = await get_database().fetch_one(query)
if not result:
return None
-
return Meeting(**result)
async def get_active(self, room: Room, current_time: datetime) -> Meeting | None:
@@ -179,7 +189,6 @@ class MeetingController:
result = await get_database().fetch_one(query)
if not result:
return None
-
return Meeting(**result)
async def get_all_active_for_room(
@@ -219,17 +228,27 @@ class MeetingController:
return None
return Meeting(**result)
- async def get_by_id(self, meeting_id: str, **kwargs) -> Meeting | None:
+ async def get_by_id(
+ self, meeting_id: str, room: Room | None = None
+ ) -> Meeting | None:
query = meetings.select().where(meetings.c.id == meeting_id)
+
+ if room:
+ query = query.where(meetings.c.room_id == room.id)
+
result = await get_database().fetch_one(query)
if not result:
return None
return Meeting(**result)
- async def get_by_calendar_event(self, calendar_event_id: str) -> Meeting | None:
+ async def get_by_calendar_event(
+ self, calendar_event_id: str, room: Room
+ ) -> Meeting | None:
query = meetings.select().where(
meetings.c.calendar_event_id == calendar_event_id
)
+ if room:
+ query = query.where(meetings.c.room_id == room.id)
result = await get_database().fetch_one(query)
if not result:
return None
@@ -239,6 +258,28 @@ class MeetingController:
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
await get_database().execute(query)
+ async def increment_num_clients(self, meeting_id: str) -> None:
+ """Atomically increment participant count."""
+ query = (
+ meetings.update()
+ .where(meetings.c.id == meeting_id)
+ .values(num_clients=meetings.c.num_clients + 1)
+ )
+ await get_database().execute(query)
+
+ async def decrement_num_clients(self, meeting_id: str) -> None:
+ """Atomically decrement participant count (min 0)."""
+ query = (
+ meetings.update()
+ .where(meetings.c.id == meeting_id)
+ .values(
+ num_clients=sa.case(
+ (meetings.c.num_clients > 0, meetings.c.num_clients - 1), else_=0
+ )
+ )
+ )
+ await get_database().execute(query)
+
class MeetingConsentController:
async def get_by_meeting_id(self, meeting_id: str) -> list[MeetingConsent]:
diff --git a/server/reflector/db/recordings.py b/server/reflector/db/recordings.py
index 0d05790d..bde4afa5 100644
--- a/server/reflector/db/recordings.py
+++ b/server/reflector/db/recordings.py
@@ -21,6 +21,7 @@ recordings = sa.Table(
server_default="pending",
),
sa.Column("meeting_id", sa.String),
+ sa.Column("track_keys", sa.JSON, nullable=True),
sa.Index("idx_recording_meeting_id", "meeting_id"),
)
@@ -28,10 +29,13 @@ recordings = sa.Table(
class Recording(BaseModel):
id: str = Field(default_factory=generate_uuid4)
bucket_name: str
+ # for single-track
object_key: str
recorded_at: datetime
status: Literal["pending", "processing", "completed", "failed"] = "pending"
meeting_id: str | None = None
+ # for multitrack reprocessing
+ track_keys: list[str] | None = None
class RecordingController:
diff --git a/server/reflector/db/rooms.py b/server/reflector/db/rooms.py
index 396c818a..1081ac38 100644
--- a/server/reflector/db/rooms.py
+++ b/server/reflector/db/rooms.py
@@ -9,6 +9,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.sql import false, or_
from reflector.db import get_database, metadata
+from reflector.schemas.platform import Platform
from reflector.utils import generate_uuid4
rooms = sqlalchemy.Table(
@@ -50,6 +51,12 @@ rooms = sqlalchemy.Table(
),
sqlalchemy.Column("ics_last_sync", sqlalchemy.DateTime(timezone=True)),
sqlalchemy.Column("ics_last_etag", sqlalchemy.Text),
+ sqlalchemy.Column(
+ "platform",
+ sqlalchemy.String,
+ nullable=True,
+ server_default=None,
+ ),
sqlalchemy.Index("idx_room_is_shared", "is_shared"),
sqlalchemy.Index("idx_room_ics_enabled", "ics_enabled"),
)
@@ -66,7 +73,7 @@ class Room(BaseModel):
is_locked: bool = False
room_mode: Literal["normal", "group"] = "normal"
recording_type: Literal["none", "local", "cloud"] = "cloud"
- recording_trigger: Literal[
+ recording_trigger: Literal[ # whereby-specific
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
is_shared: bool = False
@@ -77,6 +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
class RoomController:
@@ -130,6 +138,7 @@ class RoomController:
ics_url: str | None = None,
ics_fetch_interval: int = 300,
ics_enabled: bool = False,
+ platform: Platform | None = None,
):
"""
Add a new room
@@ -153,6 +162,7 @@ class RoomController:
ics_url=ics_url,
ics_fetch_interval=ics_fetch_interval,
ics_enabled=ics_enabled,
+ platform=platform,
)
query = rooms.insert().values(**room.model_dump())
try:
diff --git a/server/reflector/db/transcripts.py b/server/reflector/db/transcripts.py
index b82e4fe1..f9c3c057 100644
--- a/server/reflector/db/transcripts.py
+++ b/server/reflector/db/transcripts.py
@@ -21,7 +21,7 @@ from reflector.db.utils import is_postgresql
from reflector.logger import logger
from reflector.processors.types import Word as ProcessorWord
from reflector.settings import settings
-from reflector.storage import get_recordings_storage, get_transcripts_storage
+from reflector.storage import get_transcripts_storage
from reflector.utils import generate_uuid4
from reflector.utils.webvtt import topics_to_webvtt
@@ -186,6 +186,7 @@ class TranscriptParticipant(BaseModel):
id: str = Field(default_factory=generate_uuid4)
speaker: int | None
name: str
+ user_id: str | None = None
class Transcript(BaseModel):
@@ -623,7 +624,9 @@ class TranscriptController:
)
if recording:
try:
- await get_recordings_storage().delete_file(recording.object_key)
+ await get_transcripts_storage().delete_file(
+ recording.object_key, bucket=recording.bucket_name
+ )
except Exception as e:
logger.warning(
"Failed to delete recording object from S3",
@@ -725,11 +728,13 @@ class TranscriptController:
"""
Download audio from storage
"""
- transcript.audio_mp3_filename.write_bytes(
- await get_transcripts_storage().get_file(
- transcript.storage_audio_path,
- )
- )
+ storage = get_transcripts_storage()
+ try:
+ with open(transcript.audio_mp3_filename, "wb") as f:
+ await storage.stream_to_fileobj(transcript.storage_audio_path, f)
+ except Exception:
+ transcript.audio_mp3_filename.unlink(missing_ok=True)
+ raise
async def upsert_participant(
self,
diff --git a/server/reflector/pipelines/__init__.py b/server/reflector/pipelines/__init__.py
new file mode 100644
index 00000000..89d3e9de
--- /dev/null
+++ b/server/reflector/pipelines/__init__.py
@@ -0,0 +1 @@
+"""Pipeline modules for audio processing."""
diff --git a/server/reflector/pipelines/main_file_pipeline.py b/server/reflector/pipelines/main_file_pipeline.py
index 0a05d593..6f8e8011 100644
--- a/server/reflector/pipelines/main_file_pipeline.py
+++ b/server/reflector/pipelines/main_file_pipeline.py
@@ -23,23 +23,18 @@ from reflector.db.transcripts import (
transcripts_controller,
)
from reflector.logger import logger
+from reflector.pipelines import topic_processing
from reflector.pipelines.main_live_pipeline import (
PipelineMainBase,
broadcast_to_sockets,
task_cleanup_consent,
task_pipeline_post_to_zulip,
)
-from reflector.processors import (
- AudioFileWriterProcessor,
- TranscriptFinalSummaryProcessor,
- TranscriptFinalTitleProcessor,
- TranscriptTopicDetectorProcessor,
-)
+from reflector.pipelines.transcription_helpers import transcribe_file_with_processor
+from reflector.processors import AudioFileWriterProcessor
from reflector.processors.audio_waveform_processor import AudioWaveformProcessor
from reflector.processors.file_diarization import FileDiarizationInput
from reflector.processors.file_diarization_auto import FileDiarizationAutoProcessor
-from reflector.processors.file_transcript import FileTranscriptInput
-from reflector.processors.file_transcript_auto import FileTranscriptAutoProcessor
from reflector.processors.transcript_diarization_assembler import (
TranscriptDiarizationAssemblerInput,
TranscriptDiarizationAssemblerProcessor,
@@ -56,19 +51,6 @@ from reflector.storage import get_transcripts_storage
from reflector.worker.webhook import send_transcript_webhook
-class EmptyPipeline:
- """Empty pipeline for processors that need a pipeline reference"""
-
- def __init__(self, logger: structlog.BoundLogger):
- self.logger = logger
-
- def get_pref(self, k, d=None):
- return d
-
- async def emit(self, event):
- pass
-
-
class PipelineMainFile(PipelineMainBase):
"""
Optimized file processing pipeline.
@@ -81,7 +63,7 @@ class PipelineMainFile(PipelineMainBase):
def __init__(self, transcript_id: str):
super().__init__(transcript_id=transcript_id)
self.logger = logger.bind(transcript_id=self.transcript_id)
- self.empty_pipeline = EmptyPipeline(logger=self.logger)
+ self.empty_pipeline = topic_processing.EmptyPipeline(logger=self.logger)
def _handle_gather_exceptions(self, results: list, operation: str) -> None:
"""Handle exceptions from asyncio.gather with return_exceptions=True"""
@@ -262,24 +244,7 @@ class PipelineMainFile(PipelineMainBase):
async def transcribe_file(self, audio_url: str, language: str) -> TranscriptType:
"""Transcribe complete file"""
- processor = FileTranscriptAutoProcessor()
- input_data = FileTranscriptInput(audio_url=audio_url, language=language)
-
- # Store result for retrieval
- result: TranscriptType | None = None
-
- async def capture_result(transcript):
- nonlocal result
- result = transcript
-
- processor.on(capture_result)
- await processor.push(input_data)
- await processor.flush()
-
- if not result:
- raise ValueError("No transcript captured")
-
- return result
+ return await transcribe_file_with_processor(audio_url, language)
async def diarize_file(self, audio_url: str) -> list[DiarizationSegment] | None:
"""Get diarization for file"""
@@ -322,63 +287,31 @@ class PipelineMainFile(PipelineMainBase):
async def detect_topics(
self, transcript: TranscriptType, target_language: str
) -> list[TitleSummary]:
- """Detect topics from complete transcript"""
- chunk_size = 300
- topics: list[TitleSummary] = []
-
- async def on_topic(topic: TitleSummary):
- topics.append(topic)
- return await self.on_topic(topic)
-
- topic_detector = TranscriptTopicDetectorProcessor(callback=on_topic)
- topic_detector.set_pipeline(self.empty_pipeline)
-
- for i in range(0, len(transcript.words), chunk_size):
- chunk_words = transcript.words[i : i + chunk_size]
- if not chunk_words:
- continue
-
- chunk_transcript = TranscriptType(
- words=chunk_words, translation=transcript.translation
- )
-
- await topic_detector.push(chunk_transcript)
-
- await topic_detector.flush()
- return topics
+ return await topic_processing.detect_topics(
+ transcript,
+ target_language,
+ on_topic_callback=self.on_topic,
+ empty_pipeline=self.empty_pipeline,
+ )
async def generate_title(self, topics: list[TitleSummary]):
- """Generate title from topics"""
- if not topics:
- self.logger.warning("No topics for title generation")
- return
-
- processor = TranscriptFinalTitleProcessor(callback=self.on_title)
- processor.set_pipeline(self.empty_pipeline)
-
- for topic in topics:
- await processor.push(topic)
-
- await processor.flush()
+ return await topic_processing.generate_title(
+ topics,
+ on_title_callback=self.on_title,
+ empty_pipeline=self.empty_pipeline,
+ logger=self.logger,
+ )
async def generate_summaries(self, topics: list[TitleSummary]):
- """Generate long and short summaries from topics"""
- if not topics:
- self.logger.warning("No topics for summary generation")
- return
-
transcript = await self.get_transcript()
- processor = TranscriptFinalSummaryProcessor(
- transcript=transcript,
- callback=self.on_long_summary,
- on_short_summary=self.on_short_summary,
+ return await topic_processing.generate_summaries(
+ topics,
+ transcript,
+ on_long_summary_callback=self.on_long_summary,
+ on_short_summary_callback=self.on_short_summary,
+ empty_pipeline=self.empty_pipeline,
+ logger=self.logger,
)
- processor.set_pipeline(self.empty_pipeline)
-
- for topic in topics:
- await processor.push(topic)
-
- await processor.flush()
@shared_task
diff --git a/server/reflector/pipelines/main_live_pipeline.py b/server/reflector/pipelines/main_live_pipeline.py
index f6fe6a83..83e560d6 100644
--- a/server/reflector/pipelines/main_live_pipeline.py
+++ b/server/reflector/pipelines/main_live_pipeline.py
@@ -17,7 +17,6 @@ from contextlib import asynccontextmanager
from typing import Generic
import av
-import boto3
from celery import chord, current_task, group, shared_task
from pydantic import BaseModel
from structlog import BoundLogger as Logger
@@ -584,6 +583,7 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
consent_denied = False
recording = None
+ meeting = None
try:
if transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
@@ -594,8 +594,8 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
meeting.id
)
except Exception as e:
- logger.error(f"Failed to get fetch consent: {e}", exc_info=e)
- consent_denied = True
+ logger.error(f"Failed to fetch consent: {e}", exc_info=e)
+ raise
if not consent_denied:
logger.info("Consent approved, keeping all files")
@@ -603,25 +603,24 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
logger.info("Consent denied, cleaning up all related audio files")
- if recording and recording.bucket_name and recording.object_key:
- s3_whereby = boto3.client(
- "s3",
- aws_access_key_id=settings.AWS_WHEREBY_ACCESS_KEY_ID,
- aws_secret_access_key=settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
- )
- try:
- s3_whereby.delete_object(
- Bucket=recording.bucket_name, Key=recording.object_key
- )
- logger.info(
- f"Deleted original Whereby recording: {recording.bucket_name}/{recording.object_key}"
- )
- except Exception as e:
- logger.error(f"Failed to delete Whereby recording: {e}", exc_info=e)
+ deletion_errors = []
+ if recording and recording.bucket_name:
+ keys_to_delete = []
+ if recording.track_keys:
+ keys_to_delete = recording.track_keys
+ elif recording.object_key:
+ keys_to_delete = [recording.object_key]
+
+ master_storage = get_transcripts_storage()
+ for key in keys_to_delete:
+ try:
+ await master_storage.delete_file(key, bucket=recording.bucket_name)
+ logger.info(f"Deleted recording file: {recording.bucket_name}/{key}")
+ except Exception as e:
+ error_msg = f"Failed to delete {key}: {e}"
+ logger.error(error_msg, exc_info=e)
+ deletion_errors.append(error_msg)
- # non-transactional, files marked for deletion not actually deleted is possible
- await transcripts_controller.update(transcript, {"audio_deleted": True})
- # 2. Delete processed audio from transcript storage S3 bucket
if transcript.audio_location == "storage":
storage = get_transcripts_storage()
try:
@@ -630,18 +629,28 @@ async def cleanup_consent(transcript: Transcript, logger: Logger):
f"Deleted processed audio from storage: {transcript.storage_audio_path}"
)
except Exception as e:
- logger.error(f"Failed to delete processed audio: {e}", exc_info=e)
+ error_msg = f"Failed to delete processed audio: {e}"
+ logger.error(error_msg, exc_info=e)
+ deletion_errors.append(error_msg)
- # 3. Delete local audio files
try:
if hasattr(transcript, "audio_mp3_filename") and transcript.audio_mp3_filename:
transcript.audio_mp3_filename.unlink(missing_ok=True)
if hasattr(transcript, "audio_wav_filename") and transcript.audio_wav_filename:
transcript.audio_wav_filename.unlink(missing_ok=True)
except Exception as e:
- logger.error(f"Failed to delete local audio files: {e}", exc_info=e)
+ error_msg = f"Failed to delete local audio files: {e}"
+ logger.error(error_msg, exc_info=e)
+ deletion_errors.append(error_msg)
- logger.info("Consent cleanup done")
+ if deletion_errors:
+ logger.warning(
+ f"Consent cleanup completed with {len(deletion_errors)} errors",
+ errors=deletion_errors,
+ )
+ else:
+ await transcripts_controller.update(transcript, {"audio_deleted": True})
+ logger.info("Consent cleanup done - all audio deleted")
@get_transcript
diff --git a/server/reflector/pipelines/main_multitrack_pipeline.py b/server/reflector/pipelines/main_multitrack_pipeline.py
new file mode 100644
index 00000000..addcd9b4
--- /dev/null
+++ b/server/reflector/pipelines/main_multitrack_pipeline.py
@@ -0,0 +1,694 @@
+import asyncio
+import math
+import tempfile
+from fractions import Fraction
+from pathlib import Path
+
+import av
+from av.audio.resampler import AudioResampler
+from celery import chain, shared_task
+
+from reflector.asynctask import asynctask
+from reflector.db.transcripts import (
+ TranscriptStatus,
+ TranscriptWaveform,
+ transcripts_controller,
+)
+from reflector.logger import logger
+from reflector.pipelines import topic_processing
+from reflector.pipelines.main_file_pipeline import task_send_webhook_if_needed
+from reflector.pipelines.main_live_pipeline import (
+ PipelineMainBase,
+ broadcast_to_sockets,
+ task_cleanup_consent,
+ task_pipeline_post_to_zulip,
+)
+from reflector.pipelines.transcription_helpers import transcribe_file_with_processor
+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.storage import Storage, get_transcripts_storage
+from reflector.utils.string import NonEmptyString
+
+# Audio encoding constants
+OPUS_STANDARD_SAMPLE_RATE = 48000
+OPUS_DEFAULT_BIT_RATE = 128000
+
+# Storage operation constants
+PRESIGNED_URL_EXPIRATION_SECONDS = 7200 # 2 hours
+
+
+class PipelineMainMultitrack(PipelineMainBase):
+ def __init__(self, transcript_id: str):
+ super().__init__(transcript_id=transcript_id)
+ self.logger = logger.bind(transcript_id=self.transcript_id)
+ self.empty_pipeline = topic_processing.EmptyPipeline(logger=self.logger)
+
+ async def pad_track_for_transcription(
+ self,
+ track_url: NonEmptyString,
+ track_idx: int,
+ storage: Storage,
+ ) -> NonEmptyString:
+ """
+ Pad a single track with silence based on stream metadata start_time.
+ Downloads from S3 presigned URL, processes via PyAV using tempfile, uploads to S3.
+ Returns presigned URL of padded track (or original URL if no padding needed).
+
+ Memory usage:
+ - Pattern: fixed_overhead(2-5MB) for PyAV codec/filters
+ - PyAV streams input efficiently (no full download, verified)
+ - Output written to tempfile (disk-based, not memory)
+ - Upload streams from file handle (boto3 chunks, typically 5-10MB)
+
+ Daily.co raw-tracks timing - Two approaches:
+
+ CURRENT APPROACH (PyAV metadata):
+ The WebM stream.start_time field encodes MEETING-RELATIVE timing:
+ - t=0: When Daily.co recording started (first participant joined)
+ - start_time=8.13s: This participant's track began 8.13s after recording started
+ - Purpose: Enables track alignment without external manifest files
+
+ This is NOT:
+ - Stream-internal offset (first packet timestamp relative to stream start)
+ - Absolute/wall-clock time
+ - Recording duration
+
+ ALTERNATIVE APPROACH (filename parsing):
+ Daily.co filenames contain Unix timestamps (milliseconds):
+ Format: {recording_start_ts}-{participant_id}-cam-audio-{track_start_ts}.webm
+ Example: 1760988935484-52f7f48b-fbab-431f-9a50-87b9abfc8255-cam-audio-1760988935922.webm
+
+ Can calculate offset: (track_start_ts - recording_start_ts) / 1000
+ - Track 0: (1760988935922 - 1760988935484) / 1000 = 0.438s
+ - Track 1: (1760988943823 - 1760988935484) / 1000 = 8.339s
+
+ TIME DIFFERENCE: PyAV metadata vs filename timestamps differ by ~209ms:
+ - Track 0: filename=438ms, metadata=229ms (diff: 209ms)
+ - Track 1: filename=8339ms, metadata=8130ms (diff: 209ms)
+
+ Consistent delta suggests network/encoding delay. PyAV metadata is ground truth
+ (represents when audio stream actually started vs when file upload initiated).
+
+ Example with 2 participants:
+ Track A: start_time=0.2s → Joined 200ms after recording began
+ Track B: start_time=8.1s → Joined 8.1 seconds later
+
+ After padding:
+ Track A: [0.2s silence] + [speech...]
+ Track B: [8.1s silence] + [speech...]
+
+ Whisper transcription timestamps are now synchronized:
+ Track A word at 5.0s → happened at meeting t=5.0s
+ Track B word at 10.0s → happened at meeting t=10.0s
+
+ Merging just sorts by timestamp - no offset calculation needed.
+
+ Padding coincidentally involves re-encoding. It's important when we work with Daily.co + Whisper.
+ This is because Daily.co returns recordings with skipped frames e.g. when microphone muted.
+ Daily.co doesn't understand those frames and ignores them, causing timestamp issues in transcription.
+ Re-encoding restores those frames. We do padding and re-encoding together just because it's convenient and more performant:
+ we need padded values for mix mp3 anyways
+ """
+
+ transcript = await self.get_transcript()
+
+ try:
+ # PyAV streams input from S3 URL efficiently (2-5MB fixed overhead for codec/filters)
+ with av.open(track_url) as in_container:
+ start_time_seconds = self._extract_stream_start_time_from_container(
+ in_container, track_idx
+ )
+
+ if start_time_seconds <= 0:
+ self.logger.info(
+ f"Track {track_idx} requires no padding (start_time={start_time_seconds}s)",
+ track_idx=track_idx,
+ )
+ return track_url
+
+ # Use tempfile instead of BytesIO for better memory efficiency
+ # Reduces peak memory usage during encoding/upload
+ with tempfile.NamedTemporaryFile(
+ suffix=".webm", delete=False
+ ) as temp_file:
+ temp_path = temp_file.name
+
+ try:
+ self._apply_audio_padding_to_file(
+ in_container, temp_path, start_time_seconds, track_idx
+ )
+
+ storage_path = (
+ f"file_pipeline/{transcript.id}/tracks/padded_{track_idx}.webm"
+ )
+
+ # Upload using file handle for streaming
+ with open(temp_path, "rb") as padded_file:
+ await storage.put_file(storage_path, padded_file)
+ finally:
+ # Clean up temp file
+ Path(temp_path).unlink(missing_ok=True)
+
+ padded_url = await storage.get_file_url(
+ storage_path,
+ operation="get_object",
+ expires_in=PRESIGNED_URL_EXPIRATION_SECONDS,
+ )
+
+ self.logger.info(
+ f"Successfully padded track {track_idx}",
+ track_idx=track_idx,
+ start_time_seconds=start_time_seconds,
+ padded_url=padded_url,
+ )
+
+ return padded_url
+
+ except Exception as e:
+ self.logger.error(
+ f"Failed to process track {track_idx}",
+ track_idx=track_idx,
+ url=track_url,
+ error=str(e),
+ exc_info=True,
+ )
+ raise Exception(
+ f"Track {track_idx} padding failed - transcript would have incorrect timestamps"
+ ) from e
+
+ def _extract_stream_start_time_from_container(
+ self, container, track_idx: int
+ ) -> float:
+ """
+ Extract meeting-relative start time from WebM stream metadata.
+ Uses PyAV to read stream.start_time from WebM container.
+ More accurate than filename timestamps by ~209ms due to network/encoding delays.
+ """
+ start_time_seconds = 0.0
+ try:
+ audio_streams = [s for s in container.streams if s.type == "audio"]
+ stream = audio_streams[0] if audio_streams else container.streams[0]
+
+ # 1) Try stream-level start_time (most reliable for Daily.co tracks)
+ if stream.start_time is not None and stream.time_base is not None:
+ start_time_seconds = float(stream.start_time * stream.time_base)
+
+ # 2) Fallback to container-level start_time (in av.time_base units)
+ if (start_time_seconds <= 0) and (container.start_time is not None):
+ start_time_seconds = float(container.start_time * av.time_base)
+
+ # 3) Fallback to first packet DTS in stream.time_base
+ if start_time_seconds <= 0:
+ for packet in container.demux(stream):
+ if packet.dts is not None:
+ start_time_seconds = float(packet.dts * stream.time_base)
+ break
+ except Exception as e:
+ self.logger.warning(
+ "PyAV metadata read failed; assuming 0 start_time",
+ track_idx=track_idx,
+ error=str(e),
+ )
+ start_time_seconds = 0.0
+
+ self.logger.info(
+ f"Track {track_idx} stream metadata: start_time={start_time_seconds:.3f}s",
+ track_idx=track_idx,
+ )
+ return start_time_seconds
+
+ def _apply_audio_padding_to_file(
+ self,
+ in_container,
+ output_path: str,
+ start_time_seconds: float,
+ track_idx: int,
+ ) -> None:
+ """Apply silence padding to audio track using PyAV filter graph, writing to file"""
+ delay_ms = math.floor(start_time_seconds * 1000)
+
+ self.logger.info(
+ f"Padding track {track_idx} with {delay_ms}ms delay using PyAV",
+ track_idx=track_idx,
+ delay_ms=delay_ms,
+ )
+
+ try:
+ with av.open(output_path, "w", format="webm") as out_container:
+ in_stream = next(
+ (s for s in in_container.streams if s.type == "audio"), None
+ )
+ if in_stream is None:
+ raise Exception("No audio stream in input")
+
+ out_stream = out_container.add_stream(
+ "libopus", rate=OPUS_STANDARD_SAMPLE_RATE
+ )
+ out_stream.bit_rate = OPUS_DEFAULT_BIT_RATE
+ graph = av.filter.Graph()
+
+ abuf_args = (
+ f"time_base=1/{OPUS_STANDARD_SAMPLE_RATE}:"
+ f"sample_rate={OPUS_STANDARD_SAMPLE_RATE}:"
+ f"sample_fmt=s16:"
+ f"channel_layout=stereo"
+ )
+ src = graph.add("abuffer", args=abuf_args, name="src")
+ aresample_f = graph.add("aresample", args="async=1", name="ares")
+ # adelay requires one delay value per channel separated by '|'
+ delays_arg = f"{delay_ms}|{delay_ms}"
+ adelay_f = graph.add(
+ "adelay", args=f"delays={delays_arg}:all=1", name="delay"
+ )
+ sink = graph.add("abuffersink", name="sink")
+
+ src.link_to(aresample_f)
+ aresample_f.link_to(adelay_f)
+ adelay_f.link_to(sink)
+ graph.configure()
+
+ resampler = AudioResampler(
+ format="s16", layout="stereo", rate=OPUS_STANDARD_SAMPLE_RATE
+ )
+ # Decode -> resample -> push through graph -> encode Opus
+ for frame in in_container.decode(in_stream):
+ out_frames = resampler.resample(frame) or []
+ for rframe in out_frames:
+ rframe.sample_rate = OPUS_STANDARD_SAMPLE_RATE
+ rframe.time_base = Fraction(1, OPUS_STANDARD_SAMPLE_RATE)
+ src.push(rframe)
+
+ while True:
+ try:
+ f_out = sink.pull()
+ except Exception:
+ break
+ f_out.sample_rate = OPUS_STANDARD_SAMPLE_RATE
+ f_out.time_base = Fraction(1, OPUS_STANDARD_SAMPLE_RATE)
+ for packet in out_stream.encode(f_out):
+ out_container.mux(packet)
+
+ src.push(None)
+ while True:
+ try:
+ f_out = sink.pull()
+ except Exception:
+ break
+ f_out.sample_rate = OPUS_STANDARD_SAMPLE_RATE
+ f_out.time_base = Fraction(1, OPUS_STANDARD_SAMPLE_RATE)
+ for packet in out_stream.encode(f_out):
+ out_container.mux(packet)
+
+ for packet in out_stream.encode(None):
+ out_container.mux(packet)
+ except Exception as e:
+ self.logger.error(
+ "PyAV padding failed for track",
+ track_idx=track_idx,
+ delay_ms=delay_ms,
+ error=str(e),
+ exc_info=True,
+ )
+ raise
+
+ async def mixdown_tracks(
+ self,
+ track_urls: list[str],
+ writer: AudioFileWriterProcessor,
+ offsets_seconds: list[float] | None = None,
+ ) -> None:
+ """Multi-track mixdown using PyAV filter graph (amix), reading from S3 presigned URLs"""
+
+ target_sample_rate: int | None = None
+ for url in track_urls:
+ if not url:
+ continue
+ container = None
+ try:
+ container = av.open(url)
+ for frame in container.decode(audio=0):
+ target_sample_rate = frame.sample_rate
+ break
+ except Exception:
+ continue
+ finally:
+ if container is not None:
+ container.close()
+ if target_sample_rate:
+ break
+
+ if not target_sample_rate:
+ self.logger.error("Mixdown failed - no decodable audio frames found")
+ raise Exception("Mixdown failed: No decodable audio frames in any track")
+ # Build PyAV filter graph:
+ # N abuffer (s32/stereo)
+ # -> optional adelay per input (for alignment)
+ # -> amix (s32)
+ # -> aformat(s16)
+ # -> sink
+ graph = av.filter.Graph()
+ inputs = []
+ valid_track_urls = [url for url in track_urls if url]
+ input_offsets_seconds = None
+ if offsets_seconds is not None:
+ input_offsets_seconds = [
+ offsets_seconds[i] for i, url in enumerate(track_urls) if url
+ ]
+ for idx, url in enumerate(valid_track_urls):
+ args = (
+ f"time_base=1/{target_sample_rate}:"
+ f"sample_rate={target_sample_rate}:"
+ f"sample_fmt=s32:"
+ f"channel_layout=stereo"
+ )
+ in_ctx = graph.add("abuffer", args=args, name=f"in{idx}")
+ inputs.append(in_ctx)
+
+ if not inputs:
+ self.logger.error("Mixdown failed - no valid inputs for graph")
+ raise Exception("Mixdown failed: No valid inputs for filter graph")
+
+ mixer = graph.add("amix", args=f"inputs={len(inputs)}:normalize=0", name="mix")
+
+ fmt = graph.add(
+ "aformat",
+ args=(
+ f"sample_fmts=s32:channel_layouts=stereo:sample_rates={target_sample_rate}"
+ ),
+ name="fmt",
+ )
+
+ sink = graph.add("abuffersink", name="out")
+
+ # Optional per-input delay before mixing
+ delays_ms: list[int] = []
+ if input_offsets_seconds is not None:
+ base = min(input_offsets_seconds) if input_offsets_seconds else 0.0
+ delays_ms = [
+ max(0, int(round((o - base) * 1000))) for o in input_offsets_seconds
+ ]
+ else:
+ delays_ms = [0 for _ in inputs]
+
+ for idx, in_ctx in enumerate(inputs):
+ delay_ms = delays_ms[idx] if idx < len(delays_ms) else 0
+ if delay_ms > 0:
+ # adelay requires one value per channel; use same for stereo
+ adelay = graph.add(
+ "adelay",
+ args=f"delays={delay_ms}|{delay_ms}:all=1",
+ name=f"delay{idx}",
+ )
+ in_ctx.link_to(adelay)
+ adelay.link_to(mixer, 0, idx)
+ else:
+ in_ctx.link_to(mixer, 0, idx)
+ mixer.link_to(fmt)
+ fmt.link_to(sink)
+ graph.configure()
+
+ containers = []
+ try:
+ # Open all containers with cleanup guaranteed
+ for i, url in enumerate(valid_track_urls):
+ try:
+ c = av.open(url)
+ containers.append(c)
+ except Exception as e:
+ self.logger.warning(
+ "Mixdown: failed to open container from URL",
+ input=i,
+ url=url,
+ error=str(e),
+ )
+
+ if not containers:
+ self.logger.error("Mixdown failed - no valid containers opened")
+ raise Exception("Mixdown failed: Could not open any track containers")
+
+ decoders = [c.decode(audio=0) for c in containers]
+ active = [True] * len(decoders)
+ resamplers = [
+ AudioResampler(format="s32", layout="stereo", rate=target_sample_rate)
+ for _ in decoders
+ ]
+
+ while any(active):
+ for i, (dec, is_active) in enumerate(zip(decoders, active)):
+ if not is_active:
+ continue
+ try:
+ frame = next(dec)
+ except StopIteration:
+ active[i] = False
+ continue
+
+ if frame.sample_rate != target_sample_rate:
+ continue
+ out_frames = resamplers[i].resample(frame) or []
+ for rf in out_frames:
+ rf.sample_rate = target_sample_rate
+ rf.time_base = Fraction(1, target_sample_rate)
+ inputs[i].push(rf)
+
+ while True:
+ try:
+ mixed = sink.pull()
+ except Exception:
+ break
+ mixed.sample_rate = target_sample_rate
+ 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()
+ except Exception:
+ break
+ mixed.sample_rate = target_sample_rate
+ mixed.time_base = Fraction(1, target_sample_rate)
+ await writer.push(mixed)
+ finally:
+ # Cleanup all containers, even if processing failed
+ for c in containers:
+ if c is not None:
+ try:
+ c.close()
+ except Exception:
+ pass # Best effort cleanup
+
+ @broadcast_to_sockets
+ async def set_status(self, transcript_id: str, status: TranscriptStatus):
+ async with self.lock_transaction():
+ return await transcripts_controller.set_status(transcript_id, status)
+
+ async def on_waveform(self, data):
+ async with self.transaction():
+ waveform = TranscriptWaveform(waveform=data)
+ transcript = await self.get_transcript()
+ return await transcripts_controller.append_event(
+ transcript=transcript, event="WAVEFORM", data=waveform
+ )
+
+ async def process(self, bucket_name: str, track_keys: list[str]):
+ transcript = await self.get_transcript()
+ async with self.transaction():
+ await transcripts_controller.update(
+ transcript,
+ {
+ "events": [],
+ "topics": [],
+ },
+ )
+
+ source_storage = get_transcripts_storage()
+ transcript_storage = source_storage
+
+ track_urls: list[str] = []
+ for key in track_keys:
+ url = await source_storage.get_file_url(
+ key,
+ operation="get_object",
+ expires_in=PRESIGNED_URL_EXPIRATION_SECONDS,
+ bucket=bucket_name,
+ )
+ track_urls.append(url)
+ self.logger.info(
+ f"Generated presigned URL for track from {bucket_name}",
+ key=key,
+ )
+
+ created_padded_files = set()
+ padded_track_urls: list[str] = []
+ for idx, url in enumerate(track_urls):
+ padded_url = await self.pad_track_for_transcription(
+ url, idx, transcript_storage
+ )
+ padded_track_urls.append(padded_url)
+ if padded_url != url:
+ storage_path = f"file_pipeline/{transcript.id}/tracks/padded_{idx}.webm"
+ created_padded_files.add(storage_path)
+ self.logger.info(f"Track {idx} processed, padded URL: {padded_url}")
+
+ 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 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"})
+
+ 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):
+ if not padded_url:
+ continue
+
+ t = await self.transcribe_file(padded_url, transcript.source_language)
+
+ if not t.words:
+ continue
+
+ for w in t.words:
+ w.speaker = idx
+
+ speaker_transcripts.append(t)
+ self.logger.info(
+ f"Track {idx} transcribed successfully with {len(t.words)} words",
+ track_idx=idx,
+ )
+
+ valid_track_count = len([url for url in padded_track_urls if url])
+ if valid_track_count > 0 and len(speaker_transcripts) != valid_track_count:
+ raise Exception(
+ f"Only {len(speaker_transcripts)}/{valid_track_count} tracks transcribed successfully. "
+ f"All tracks must succeed to avoid incomplete transcripts."
+ )
+
+ if not speaker_transcripts:
+ raise Exception("No valid track transcriptions")
+
+ self.logger.info(f"Cleaning up {len(created_padded_files)} temporary S3 files")
+ cleanup_tasks = []
+ for storage_path in created_padded_files:
+ cleanup_tasks.append(transcript_storage.delete_file(storage_path))
+
+ if cleanup_tasks:
+ cleanup_results = await asyncio.gather(
+ *cleanup_tasks, return_exceptions=True
+ )
+ for storage_path, result in zip(created_padded_files, cleanup_results):
+ if isinstance(result, Exception):
+ self.logger.warning(
+ "Failed to cleanup temporary padded track",
+ storage_path=storage_path,
+ error=str(result),
+ )
+
+ merged_words = []
+ for t in speaker_transcripts:
+ merged_words.extend(t.words)
+ merged_words.sort(
+ key=lambda w: w.start if hasattr(w, "start") and w.start is not None else 0
+ )
+
+ merged_transcript = TranscriptType(words=merged_words, translation=None)
+
+ await self.on_transcript(merged_transcript)
+
+ topics = await self.detect_topics(merged_transcript, transcript.target_language)
+ await asyncio.gather(
+ self.generate_title(topics),
+ self.generate_summaries(topics),
+ return_exceptions=False,
+ )
+
+ await self.set_status(transcript.id, "ended")
+
+ async def transcribe_file(self, audio_url: str, language: str) -> TranscriptType:
+ return await transcribe_file_with_processor(audio_url, language)
+
+ async def detect_topics(
+ self, transcript: TranscriptType, target_language: str
+ ) -> list[TitleSummary]:
+ return await topic_processing.detect_topics(
+ transcript,
+ target_language,
+ on_topic_callback=self.on_topic,
+ empty_pipeline=self.empty_pipeline,
+ )
+
+ async def generate_title(self, topics: list[TitleSummary]):
+ return await topic_processing.generate_title(
+ topics,
+ on_title_callback=self.on_title,
+ empty_pipeline=self.empty_pipeline,
+ logger=self.logger,
+ )
+
+ async def generate_summaries(self, topics: list[TitleSummary]):
+ transcript = await self.get_transcript()
+ return await topic_processing.generate_summaries(
+ topics,
+ transcript,
+ on_long_summary_callback=self.on_long_summary,
+ on_short_summary_callback=self.on_short_summary,
+ empty_pipeline=self.empty_pipeline,
+ logger=self.logger,
+ )
+
+
+@shared_task
+@asynctask
+async def task_pipeline_multitrack_process(
+ *, transcript_id: str, bucket_name: str, track_keys: list[str]
+):
+ pipeline = PipelineMainMultitrack(transcript_id=transcript_id)
+ try:
+ await pipeline.set_status(transcript_id, "processing")
+ await pipeline.process(bucket_name, track_keys)
+ except Exception:
+ await pipeline.set_status(transcript_id, "error")
+ raise
+
+ post_chain = chain(
+ task_cleanup_consent.si(transcript_id=transcript_id),
+ task_pipeline_post_to_zulip.si(transcript_id=transcript_id),
+ task_send_webhook_if_needed.si(transcript_id=transcript_id),
+ )
+ post_chain.delay()
diff --git a/server/reflector/pipelines/topic_processing.py b/server/reflector/pipelines/topic_processing.py
new file mode 100644
index 00000000..7f055025
--- /dev/null
+++ b/server/reflector/pipelines/topic_processing.py
@@ -0,0 +1,109 @@
+"""
+Topic processing utilities
+==========================
+
+Shared topic detection, title generation, and summarization logic
+used across file and multitrack pipelines.
+"""
+
+from typing import Callable
+
+import structlog
+
+from reflector.db.transcripts import Transcript
+from reflector.processors import (
+ TranscriptFinalSummaryProcessor,
+ TranscriptFinalTitleProcessor,
+ TranscriptTopicDetectorProcessor,
+)
+from reflector.processors.types import TitleSummary
+from reflector.processors.types import Transcript as TranscriptType
+
+
+class EmptyPipeline:
+ def __init__(self, logger: structlog.BoundLogger):
+ self.logger = logger
+
+ def get_pref(self, k, d=None):
+ return d
+
+ async def emit(self, event):
+ pass
+
+
+async def detect_topics(
+ transcript: TranscriptType,
+ target_language: str,
+ *,
+ on_topic_callback: Callable,
+ empty_pipeline: EmptyPipeline,
+) -> list[TitleSummary]:
+ chunk_size = 300
+ topics: list[TitleSummary] = []
+
+ async def on_topic(topic: TitleSummary):
+ topics.append(topic)
+ return await on_topic_callback(topic)
+
+ topic_detector = TranscriptTopicDetectorProcessor(callback=on_topic)
+ topic_detector.set_pipeline(empty_pipeline)
+
+ for i in range(0, len(transcript.words), chunk_size):
+ chunk_words = transcript.words[i : i + chunk_size]
+ if not chunk_words:
+ continue
+
+ chunk_transcript = TranscriptType(
+ words=chunk_words, translation=transcript.translation
+ )
+
+ await topic_detector.push(chunk_transcript)
+
+ await topic_detector.flush()
+ return topics
+
+
+async def generate_title(
+ topics: list[TitleSummary],
+ *,
+ on_title_callback: Callable,
+ empty_pipeline: EmptyPipeline,
+ logger: structlog.BoundLogger,
+):
+ if not topics:
+ logger.warning("No topics for title generation")
+ return
+
+ processor = TranscriptFinalTitleProcessor(callback=on_title_callback)
+ processor.set_pipeline(empty_pipeline)
+
+ for topic in topics:
+ await processor.push(topic)
+
+ await processor.flush()
+
+
+async def generate_summaries(
+ topics: list[TitleSummary],
+ transcript: Transcript,
+ *,
+ on_long_summary_callback: Callable,
+ on_short_summary_callback: Callable,
+ empty_pipeline: EmptyPipeline,
+ logger: structlog.BoundLogger,
+):
+ if not topics:
+ logger.warning("No topics for summary generation")
+ return
+
+ processor = TranscriptFinalSummaryProcessor(
+ transcript=transcript,
+ callback=on_long_summary_callback,
+ on_short_summary=on_short_summary_callback,
+ )
+ processor.set_pipeline(empty_pipeline)
+
+ for topic in topics:
+ await processor.push(topic)
+
+ await processor.flush()
diff --git a/server/reflector/pipelines/transcription_helpers.py b/server/reflector/pipelines/transcription_helpers.py
new file mode 100644
index 00000000..b0cc5858
--- /dev/null
+++ b/server/reflector/pipelines/transcription_helpers.py
@@ -0,0 +1,34 @@
+from reflector.processors.file_transcript import FileTranscriptInput
+from reflector.processors.file_transcript_auto import FileTranscriptAutoProcessor
+from reflector.processors.types import Transcript as TranscriptType
+
+
+async def transcribe_file_with_processor(
+ audio_url: str,
+ language: str,
+ processor_name: str | None = None,
+) -> TranscriptType:
+ processor = (
+ FileTranscriptAutoProcessor(name=processor_name)
+ if processor_name
+ else FileTranscriptAutoProcessor()
+ )
+ input_data = FileTranscriptInput(audio_url=audio_url, language=language)
+
+ result: TranscriptType | None = None
+
+ async def capture_result(transcript):
+ nonlocal result
+ result = transcript
+
+ processor.on(capture_result)
+ await processor.push(input_data)
+ await processor.flush()
+
+ if not result:
+ processor_label = processor_name or "default"
+ raise ValueError(
+ f"No transcript captured from {processor_label} processor for audio: {audio_url}"
+ )
+
+ return result
diff --git a/server/reflector/processors/summary/summary_builder.py b/server/reflector/processors/summary/summary_builder.py
index efcf9227..df348093 100644
--- a/server/reflector/processors/summary/summary_builder.py
+++ b/server/reflector/processors/summary/summary_builder.py
@@ -165,6 +165,7 @@ class SummaryBuilder:
self.llm: LLM = llm
self.model_name: str = llm.model_name
self.logger = logger or structlog.get_logger()
+ self.participant_instructions: str | None = None
if filename:
self.read_transcript_from_file(filename)
@@ -191,14 +192,61 @@ class SummaryBuilder:
self, prompt: str, output_cls: Type[T], tone_name: str | None = None
) -> T:
"""Generic function to get structured output from LLM for non-function-calling models."""
+ # Add participant instructions to the prompt if available
+ enhanced_prompt = self._enhance_prompt_with_participants(prompt)
return await self.llm.get_structured_response(
- prompt, [self.transcript], output_cls, tone_name=tone_name
+ enhanced_prompt, [self.transcript], output_cls, tone_name=tone_name
)
+ async def _get_response(
+ self, prompt: str, texts: list[str], tone_name: str | None = None
+ ) -> str:
+ """Get text response with automatic participant instructions injection."""
+ enhanced_prompt = self._enhance_prompt_with_participants(prompt)
+ return await self.llm.get_response(enhanced_prompt, texts, tone_name=tone_name)
+
+ def _enhance_prompt_with_participants(self, prompt: str) -> str:
+ """Add participant instructions to any prompt if participants are known."""
+ if self.participant_instructions:
+ self.logger.debug("Adding participant instructions to prompt")
+ return f"{prompt}\n\n{self.participant_instructions}"
+ return prompt
+
# ----------------------------------------------------------------------------
# Participants
# ----------------------------------------------------------------------------
+ def set_known_participants(self, participants: list[str]) -> None:
+ """
+ Set known participants directly without LLM identification.
+ This is used when participants are already identified and stored.
+ They are appended at the end of the transcript, providing more context for the assistant.
+ """
+ if not participants:
+ self.logger.warning("No participants provided")
+ return
+
+ self.logger.info(
+ "Using known participants",
+ participants=participants,
+ )
+
+ participants_md = self.format_list_md(participants)
+ self.transcript += f"\n\n# Participants\n\n{participants_md}"
+
+ # Set instructions that will be automatically added to all prompts
+ participants_list = ", ".join(participants)
+ self.participant_instructions = dedent(
+ f"""
+ # IMPORTANT: Participant Names
+ The following participants are identified in this conversation: {participants_list}
+
+ You MUST use these specific participant names when referring to people in your response.
+ Do NOT use generic terms like "a participant", "someone", "attendee", "Speaker 1", "Speaker 2", etc.
+ Always refer to people by their actual names (e.g., "John suggested..." not "A participant suggested...").
+ """
+ ).strip()
+
async def identify_participants(self) -> None:
"""
From a transcript, try to identify the participants using TreeSummarize with structured output.
@@ -232,6 +280,19 @@ class SummaryBuilder:
if unique_participants:
participants_md = self.format_list_md(unique_participants)
self.transcript += f"\n\n# Participants\n\n{participants_md}"
+
+ # Set instructions that will be automatically added to all prompts
+ participants_list = ", ".join(unique_participants)
+ self.participant_instructions = dedent(
+ f"""
+ # IMPORTANT: Participant Names
+ The following participants are identified in this conversation: {participants_list}
+
+ You MUST use these specific participant names when referring to people in your response.
+ Do NOT use generic terms like "a participant", "someone", "attendee", "Speaker 1", "Speaker 2", etc.
+ Always refer to people by their actual names (e.g., "John suggested..." not "A participant suggested...").
+ """
+ ).strip()
else:
self.logger.warning("No participants identified in the transcript")
@@ -318,13 +379,13 @@ class SummaryBuilder:
for subject in self.subjects:
detailed_prompt = DETAILED_SUBJECT_PROMPT_TEMPLATE.format(subject=subject)
- detailed_response = await self.llm.get_response(
+ detailed_response = await self._get_response(
detailed_prompt, [self.transcript], tone_name="Topic assistant"
)
paragraph_prompt = PARAGRAPH_SUMMARY_PROMPT
- paragraph_response = await self.llm.get_response(
+ paragraph_response = await self._get_response(
paragraph_prompt, [str(detailed_response)], tone_name="Topic summarizer"
)
@@ -345,7 +406,7 @@ class SummaryBuilder:
recap_prompt = RECAP_PROMPT
- recap_response = await self.llm.get_response(
+ recap_response = await self._get_response(
recap_prompt, [summaries_text], tone_name="Recap summarizer"
)
diff --git a/server/reflector/processors/transcript_final_summary.py b/server/reflector/processors/transcript_final_summary.py
index 0b4a594c..dfe07aad 100644
--- a/server/reflector/processors/transcript_final_summary.py
+++ b/server/reflector/processors/transcript_final_summary.py
@@ -26,7 +26,25 @@ class TranscriptFinalSummaryProcessor(Processor):
async def get_summary_builder(self, text) -> SummaryBuilder:
builder = SummaryBuilder(self.llm, logger=self.logger)
builder.set_transcript(text)
- await builder.identify_participants()
+
+ # Use known participants if available, otherwise identify them
+ if self.transcript and self.transcript.participants:
+ # Extract participant names from the stored participants
+ participant_names = [p.name for p in self.transcript.participants if p.name]
+ if participant_names:
+ self.logger.info(
+ f"Using {len(participant_names)} known participants from transcript"
+ )
+ builder.set_known_participants(participant_names)
+ else:
+ self.logger.info(
+ "Participants field exists but is empty, identifying participants"
+ )
+ await builder.identify_participants()
+ else:
+ self.logger.info("No participants stored, identifying participants")
+ await builder.identify_participants()
+
await builder.generate_summary()
return builder
@@ -49,18 +67,30 @@ class TranscriptFinalSummaryProcessor(Processor):
speakermap = {}
if self.transcript:
speakermap = {
- participant["speaker"]: participant["name"]
- for participant in self.transcript.participants
+ p.speaker: p.name
+ for p in (self.transcript.participants or [])
+ if p.speaker is not None and p.name
}
+ self.logger.info(
+ f"Built speaker map with {len(speakermap)} participants",
+ speakermap=speakermap,
+ )
# build the transcript as a single string
- # XXX: unsure if the participants name as replaced directly in speaker ?
+ # Replace speaker IDs with actual participant names if available
text_transcript = []
+ unique_speakers = set()
for topic in self.chunks:
for segment in topic.transcript.as_segments():
name = speakermap.get(segment.speaker, f"Speaker {segment.speaker}")
+ unique_speakers.add((segment.speaker, name))
text_transcript.append(f"{name}: {segment.text}")
+ self.logger.info(
+ f"Built transcript with {len(unique_speakers)} unique speakers",
+ speakers=list(unique_speakers),
+ )
+
text_transcript = "\n".join(text_transcript)
last_chunk = self.chunks[-1]
diff --git a/server/reflector/processors/transcript_topic_detector.py b/server/reflector/processors/transcript_topic_detector.py
index 317e2d9c..695d3af3 100644
--- a/server/reflector/processors/transcript_topic_detector.py
+++ b/server/reflector/processors/transcript_topic_detector.py
@@ -1,6 +1,6 @@
from textwrap import dedent
-from pydantic import BaseModel, Field
+from pydantic import AliasChoices, BaseModel, Field
from reflector.llm import LLM
from reflector.processors.base import Processor
@@ -36,15 +36,13 @@ class TopicResponse(BaseModel):
title: str = Field(
description="A descriptive title for the topic being discussed",
- validation_alias="Title",
+ validation_alias=AliasChoices("title", "Title"),
)
summary: str = Field(
description="A concise 1-2 sentence summary of the discussion",
- validation_alias="Summary",
+ validation_alias=AliasChoices("summary", "Summary"),
)
- model_config = {"populate_by_name": True}
-
class TranscriptTopicDetectorProcessor(Processor):
"""
diff --git a/server/reflector/schemas/platform.py b/server/reflector/schemas/platform.py
new file mode 100644
index 00000000..7b945841
--- /dev/null
+++ b/server/reflector/schemas/platform.py
@@ -0,0 +1,5 @@
+from typing import Literal
+
+Platform = Literal["whereby", "daily"]
+WHEREBY_PLATFORM: Platform = "whereby"
+DAILY_PLATFORM: Platform = "daily"
diff --git a/server/reflector/settings.py b/server/reflector/settings.py
index 9659f648..0e3fb3f7 100644
--- a/server/reflector/settings.py
+++ b/server/reflector/settings.py
@@ -1,6 +1,7 @@
from pydantic.types import PositiveInt
from pydantic_settings import BaseSettings, SettingsConfigDict
+from reflector.schemas.platform import WHEREBY_PLATFORM, Platform
from reflector.utils.string import NonEmptyString
@@ -47,14 +48,17 @@ class Settings(BaseSettings):
TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
- # Recording storage
- RECORDING_STORAGE_BACKEND: str | None = None
+ # Platform-specific recording storage (follows {PREFIX}_STORAGE_AWS_{CREDENTIAL} pattern)
+ # Whereby storage configuration
+ WHEREBY_STORAGE_AWS_BUCKET_NAME: str | None = None
+ WHEREBY_STORAGE_AWS_REGION: str | None = None
+ WHEREBY_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
+ WHEREBY_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
- # Recording storage configuration for AWS
- RECORDING_STORAGE_AWS_BUCKET_NAME: str = "recording-bucket"
- RECORDING_STORAGE_AWS_REGION: str = "us-east-1"
- RECORDING_STORAGE_AWS_ACCESS_KEY_ID: str | None = None
- RECORDING_STORAGE_AWS_SECRET_ACCESS_KEY: str | None = None
+ # Daily.co storage configuration
+ DAILYCO_STORAGE_AWS_BUCKET_NAME: str | None = None
+ DAILYCO_STORAGE_AWS_REGION: str | None = None
+ DAILYCO_STORAGE_AWS_ROLE_ARN: str | None = None
# Translate into the target language
TRANSLATION_BACKEND: str = "passthrough"
@@ -124,11 +128,20 @@ class Settings(BaseSettings):
WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
WHEREBY_API_KEY: NonEmptyString | None = None
WHEREBY_WEBHOOK_SECRET: str | None = None
- AWS_WHEREBY_ACCESS_KEY_ID: str | None = None
- AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None
AWS_PROCESS_RECORDING_QUEUE_URL: str | None = None
SQS_POLLING_TIMEOUT_SECONDS: int = 60
+ # Daily.co integration
+ DAILY_API_KEY: str | None = None
+ DAILY_WEBHOOK_SECRET: str | None = None
+ DAILY_SUBDOMAIN: str | None = None
+ DAILY_WEBHOOK_UUID: str | None = (
+ None # Webhook UUID for this environment. Not used by production code
+ )
+
+ # Platform Configuration
+ DEFAULT_VIDEO_PLATFORM: Platform = WHEREBY_PLATFORM
+
# Zulip integration
ZULIP_REALM: str | None = None
ZULIP_API_KEY: str | None = None
diff --git a/server/reflector/storage/__init__.py b/server/reflector/storage/__init__.py
index 3db8a77b..aff6c767 100644
--- a/server/reflector/storage/__init__.py
+++ b/server/reflector/storage/__init__.py
@@ -3,6 +3,13 @@ from reflector.settings import settings
def get_transcripts_storage() -> Storage:
+ """
+ Get storage for processed transcript files (master credentials).
+
+ Also use this for ALL our file operations with bucket override:
+ master = get_transcripts_storage()
+ master.delete_file(key, bucket=recording.bucket_name)
+ """
assert settings.TRANSCRIPT_STORAGE_BACKEND
return Storage.get_instance(
name=settings.TRANSCRIPT_STORAGE_BACKEND,
@@ -10,8 +17,53 @@ def get_transcripts_storage() -> Storage:
)
-def get_recordings_storage() -> Storage:
+def get_whereby_storage() -> Storage:
+ """
+ Get storage config for Whereby (for passing to Whereby API).
+
+ Usage:
+ whereby_storage = get_whereby_storage()
+ key_id, secret = whereby_storage.key_credentials
+ whereby_api.create_meeting(
+ bucket=whereby_storage.bucket_name,
+ access_key_id=key_id,
+ secret=secret,
+ )
+
+ Do NOT use for our file operations - use get_transcripts_storage() instead.
+ """
+ if not settings.WHEREBY_STORAGE_AWS_BUCKET_NAME:
+ raise ValueError(
+ "WHEREBY_STORAGE_AWS_BUCKET_NAME required for Whereby with AWS storage"
+ )
+
return Storage.get_instance(
- name=settings.RECORDING_STORAGE_BACKEND,
- settings_prefix="RECORDING_STORAGE_",
+ name="aws",
+ settings_prefix="WHEREBY_STORAGE_",
+ )
+
+
+def get_dailyco_storage() -> Storage:
+ """
+ Get storage config for Daily.co (for passing to Daily API).
+
+ Usage:
+ daily_storage = get_dailyco_storage()
+ daily_api.create_meeting(
+ bucket=daily_storage.bucket_name,
+ region=daily_storage.region,
+ role_arn=daily_storage.role_credential,
+ )
+
+ Do NOT use for our file operations - use get_transcripts_storage() instead.
+ """
+ # Fail fast if platform-specific config missing
+ if not settings.DAILYCO_STORAGE_AWS_BUCKET_NAME:
+ raise ValueError(
+ "DAILYCO_STORAGE_AWS_BUCKET_NAME required for Daily.co with AWS storage"
+ )
+
+ return Storage.get_instance(
+ name="aws",
+ settings_prefix="DAILYCO_STORAGE_",
)
diff --git a/server/reflector/storage/base.py b/server/reflector/storage/base.py
index 360930d8..ba4316d8 100644
--- a/server/reflector/storage/base.py
+++ b/server/reflector/storage/base.py
@@ -1,10 +1,23 @@
import importlib
+from typing import BinaryIO, Union
from pydantic import BaseModel
from reflector.settings import settings
+class StorageError(Exception):
+ """Base exception for storage operations."""
+
+ pass
+
+
+class StoragePermissionError(StorageError):
+ """Exception raised when storage operation fails due to permission issues."""
+
+ pass
+
+
class FileResult(BaseModel):
filename: str
url: str
@@ -36,26 +49,113 @@ class Storage:
return cls._registry[name](**config)
- async def put_file(self, filename: str, data: bytes) -> FileResult:
- return await self._put_file(filename, data)
-
- async def _put_file(self, filename: str, data: bytes) -> FileResult:
+ # Credential properties for API passthrough
+ @property
+ def bucket_name(self) -> str:
+ """Default bucket name for this storage instance."""
raise NotImplementedError
- async def delete_file(self, filename: str):
- return await self._delete_file(filename)
-
- async def _delete_file(self, filename: str):
+ @property
+ def region(self) -> str:
+ """AWS region for this storage instance."""
raise NotImplementedError
- async def get_file_url(self, filename: str) -> str:
- return await self._get_file_url(filename)
+ @property
+ def access_key_id(self) -> str | None:
+ """AWS access key ID (None for role-based auth). Prefer key_credentials property."""
+ return None
- async def _get_file_url(self, filename: str) -> str:
+ @property
+ def secret_access_key(self) -> str | None:
+ """AWS secret access key (None for role-based auth). Prefer key_credentials property."""
+ return None
+
+ @property
+ def role_arn(self) -> str | None:
+ """AWS IAM role ARN for role-based auth (None for key-based auth). Prefer role_credential property."""
+ return None
+
+ @property
+ def key_credentials(self) -> tuple[str, str]:
+ """
+ Get (access_key_id, secret_access_key) for key-based auth.
+ Raises ValueError if storage uses IAM role instead.
+ """
raise NotImplementedError
- async def get_file(self, filename: str):
- return await self._get_file(filename)
-
- async def _get_file(self, filename: str):
+ @property
+ def role_credential(self) -> str:
+ """
+ Get IAM role ARN for role-based auth.
+ Raises ValueError if storage uses access keys instead.
+ """
+ raise NotImplementedError
+
+ async def put_file(
+ self, filename: str, data: Union[bytes, BinaryIO], *, bucket: str | None = None
+ ) -> FileResult:
+ """Upload data. bucket: override instance default if provided."""
+ return await self._put_file(filename, data, bucket=bucket)
+
+ async def _put_file(
+ self, filename: str, data: Union[bytes, BinaryIO], *, bucket: str | None = None
+ ) -> FileResult:
+ raise NotImplementedError
+
+ async def delete_file(self, filename: str, *, bucket: str | None = None):
+ """Delete file. bucket: override instance default if provided."""
+ return await self._delete_file(filename, bucket=bucket)
+
+ async def _delete_file(self, filename: str, *, bucket: str | None = None):
+ raise NotImplementedError
+
+ async def get_file_url(
+ self,
+ filename: str,
+ operation: str = "get_object",
+ expires_in: int = 3600,
+ *,
+ bucket: str | None = None,
+ ) -> str:
+ """Generate presigned URL. bucket: override instance default if provided."""
+ return await self._get_file_url(filename, operation, expires_in, bucket=bucket)
+
+ async def _get_file_url(
+ self,
+ filename: str,
+ operation: str = "get_object",
+ expires_in: int = 3600,
+ *,
+ bucket: str | None = None,
+ ) -> str:
+ raise NotImplementedError
+
+ async def get_file(self, filename: str, *, bucket: str | None = None):
+ """Download file. bucket: override instance default if provided."""
+ return await self._get_file(filename, bucket=bucket)
+
+ async def _get_file(self, filename: str, *, bucket: str | None = None):
+ raise NotImplementedError
+
+ async def list_objects(
+ self, prefix: str = "", *, bucket: str | None = None
+ ) -> list[str]:
+ """List object keys. bucket: override instance default if provided."""
+ return await self._list_objects(prefix, bucket=bucket)
+
+ async def _list_objects(
+ self, prefix: str = "", *, bucket: str | None = None
+ ) -> list[str]:
+ raise NotImplementedError
+
+ async def stream_to_fileobj(
+ self, filename: str, fileobj: BinaryIO, *, bucket: str | None = None
+ ):
+ """Stream file directly to file object without loading into memory.
+ bucket: override instance default if provided."""
+ return await self._stream_to_fileobj(filename, fileobj, bucket=bucket)
+
+ async def _stream_to_fileobj(
+ self, filename: str, fileobj: BinaryIO, *, bucket: str | None = None
+ ):
raise NotImplementedError
diff --git a/server/reflector/storage/storage_aws.py b/server/reflector/storage/storage_aws.py
index de9ccf35..372af4aa 100644
--- a/server/reflector/storage/storage_aws.py
+++ b/server/reflector/storage/storage_aws.py
@@ -1,79 +1,236 @@
+from functools import wraps
+from typing import BinaryIO, Union
+
import aioboto3
+from botocore.config import Config
+from botocore.exceptions import ClientError
from reflector.logger import logger
-from reflector.storage.base import FileResult, Storage
+from reflector.storage.base import FileResult, Storage, StoragePermissionError
+
+
+def handle_s3_client_errors(operation_name: str):
+ """Decorator to handle S3 ClientError with bucket-aware messaging.
+
+ Args:
+ operation_name: Human-readable operation name for error messages (e.g., "upload", "delete")
+ """
+
+ def decorator(func):
+ @wraps(func)
+ async def wrapper(self, *args, **kwargs):
+ bucket = kwargs.get("bucket")
+ try:
+ return await func(self, *args, **kwargs)
+ except ClientError as e:
+ error_code = e.response.get("Error", {}).get("Code")
+ if error_code in ("AccessDenied", "NoSuchBucket"):
+ actual_bucket = bucket or self._bucket_name
+ bucket_context = (
+ f"overridden bucket '{actual_bucket}'"
+ if bucket
+ else f"default bucket '{actual_bucket}'"
+ )
+ raise StoragePermissionError(
+ f"S3 {operation_name} failed for {bucket_context}: {error_code}. "
+ f"Check TRANSCRIPT_STORAGE_AWS_* credentials have permission."
+ ) from e
+ raise
+
+ return wrapper
+
+ return decorator
class AwsStorage(Storage):
+ """AWS S3 storage with bucket override for multi-platform recording architecture.
+ Master credentials access all buckets via optional bucket parameter in operations."""
+
def __init__(
self,
- aws_access_key_id: str,
- aws_secret_access_key: str,
aws_bucket_name: str,
aws_region: str,
+ aws_access_key_id: str | None = None,
+ aws_secret_access_key: str | None = None,
+ aws_role_arn: str | None = None,
):
- if not aws_access_key_id:
- raise ValueError("Storage `aws_storage` require `aws_access_key_id`")
- if not aws_secret_access_key:
- raise ValueError("Storage `aws_storage` require `aws_secret_access_key`")
if not aws_bucket_name:
raise ValueError("Storage `aws_storage` require `aws_bucket_name`")
if not aws_region:
raise ValueError("Storage `aws_storage` require `aws_region`")
+ if not aws_access_key_id and not aws_role_arn:
+ raise ValueError(
+ "Storage `aws_storage` require either `aws_access_key_id` or `aws_role_arn`"
+ )
+ if aws_role_arn and (aws_access_key_id or aws_secret_access_key):
+ raise ValueError(
+ "Storage `aws_storage` cannot use both `aws_role_arn` and access keys"
+ )
super().__init__()
- self.aws_bucket_name = aws_bucket_name
+ self._bucket_name = aws_bucket_name
+ self._region = aws_region
+ self._access_key_id = aws_access_key_id
+ self._secret_access_key = aws_secret_access_key
+ self._role_arn = aws_role_arn
+
self.aws_folder = ""
if "/" in aws_bucket_name:
- self.aws_bucket_name, self.aws_folder = aws_bucket_name.split("/", 1)
+ self._bucket_name, self.aws_folder = aws_bucket_name.split("/", 1)
+ self.boto_config = Config(retries={"max_attempts": 3, "mode": "adaptive"})
self.session = aioboto3.Session(
aws_access_key_id=aws_access_key_id,
aws_secret_access_key=aws_secret_access_key,
region_name=aws_region,
)
- self.base_url = f"https://{aws_bucket_name}.s3.amazonaws.com/"
+ self.base_url = f"https://{self._bucket_name}.s3.amazonaws.com/"
- async def _put_file(self, filename: str, data: bytes) -> FileResult:
- bucket = self.aws_bucket_name
- folder = self.aws_folder
- logger.info(f"Uploading {filename} to S3 {bucket}/{folder}")
- s3filename = f"{folder}/{filename}" if folder else filename
- async with self.session.client("s3") as client:
- await client.put_object(
- Bucket=bucket,
- Key=s3filename,
- Body=data,
+ # Implement credential properties
+ @property
+ def bucket_name(self) -> str:
+ return self._bucket_name
+
+ @property
+ def region(self) -> str:
+ return self._region
+
+ @property
+ def access_key_id(self) -> str | None:
+ return self._access_key_id
+
+ @property
+ def secret_access_key(self) -> str | None:
+ return self._secret_access_key
+
+ @property
+ def role_arn(self) -> str | None:
+ return self._role_arn
+
+ @property
+ def key_credentials(self) -> tuple[str, str]:
+ """Get (access_key_id, secret_access_key) for key-based auth."""
+ if self._role_arn:
+ raise ValueError(
+ "Storage uses IAM role authentication. "
+ "Use role_credential property instead of key_credentials."
)
+ if not self._access_key_id or not self._secret_access_key:
+ raise ValueError("Storage access key credentials not configured")
+ return (self._access_key_id, self._secret_access_key)
- async def _get_file_url(self, filename: str) -> FileResult:
- bucket = self.aws_bucket_name
+ @property
+ def role_credential(self) -> str:
+ """Get IAM role ARN for role-based auth."""
+ if self._access_key_id or self._secret_access_key:
+ raise ValueError(
+ "Storage uses access key authentication. "
+ "Use key_credentials property instead of role_credential."
+ )
+ if not self._role_arn:
+ raise ValueError("Storage IAM role ARN not configured")
+ return self._role_arn
+
+ @handle_s3_client_errors("upload")
+ async def _put_file(
+ self, filename: str, data: Union[bytes, BinaryIO], *, bucket: str | None = None
+ ) -> FileResult:
+ actual_bucket = bucket or self._bucket_name
folder = self.aws_folder
s3filename = f"{folder}/{filename}" if folder else filename
- async with self.session.client("s3") as client:
+ logger.info(f"Uploading {filename} to S3 {actual_bucket}/{folder}")
+
+ async with self.session.client("s3", config=self.boto_config) as client:
+ if isinstance(data, bytes):
+ await client.put_object(Bucket=actual_bucket, Key=s3filename, Body=data)
+ else:
+ # boto3 reads file-like object in chunks
+ # avoids creating extra memory copy vs bytes.getvalue() approach
+ await client.upload_fileobj(data, Bucket=actual_bucket, Key=s3filename)
+
+ url = await self._get_file_url(filename, bucket=bucket)
+ return FileResult(filename=filename, url=url)
+
+ @handle_s3_client_errors("presign")
+ async def _get_file_url(
+ self,
+ filename: str,
+ operation: str = "get_object",
+ expires_in: int = 3600,
+ *,
+ bucket: str | None = None,
+ ) -> str:
+ actual_bucket = bucket or self._bucket_name
+ folder = self.aws_folder
+ s3filename = f"{folder}/{filename}" if folder else filename
+ async with self.session.client("s3", config=self.boto_config) as client:
presigned_url = await client.generate_presigned_url(
- "get_object",
- Params={"Bucket": bucket, "Key": s3filename},
- ExpiresIn=3600,
+ operation,
+ Params={"Bucket": actual_bucket, "Key": s3filename},
+ ExpiresIn=expires_in,
)
return presigned_url
- async def _delete_file(self, filename: str):
- bucket = self.aws_bucket_name
+ @handle_s3_client_errors("delete")
+ async def _delete_file(self, filename: str, *, bucket: str | None = None):
+ actual_bucket = bucket or self._bucket_name
folder = self.aws_folder
- logger.info(f"Deleting {filename} from S3 {bucket}/{folder}")
+ logger.info(f"Deleting {filename} from S3 {actual_bucket}/{folder}")
s3filename = f"{folder}/{filename}" if folder else filename
- async with self.session.client("s3") as client:
- await client.delete_object(Bucket=bucket, Key=s3filename)
+ async with self.session.client("s3", config=self.boto_config) as client:
+ await client.delete_object(Bucket=actual_bucket, Key=s3filename)
- async def _get_file(self, filename: str):
- bucket = self.aws_bucket_name
+ @handle_s3_client_errors("download")
+ async def _get_file(self, filename: str, *, bucket: str | None = None):
+ actual_bucket = bucket or self._bucket_name
folder = self.aws_folder
- logger.info(f"Downloading {filename} from S3 {bucket}/{folder}")
+ logger.info(f"Downloading {filename} from S3 {actual_bucket}/{folder}")
s3filename = f"{folder}/{filename}" if folder else filename
- async with self.session.client("s3") as client:
- response = await client.get_object(Bucket=bucket, Key=s3filename)
+ async with self.session.client("s3", config=self.boto_config) as client:
+ response = await client.get_object(Bucket=actual_bucket, Key=s3filename)
return await response["Body"].read()
+ @handle_s3_client_errors("list_objects")
+ async def _list_objects(
+ self, prefix: str = "", *, bucket: str | None = None
+ ) -> list[str]:
+ actual_bucket = bucket or self._bucket_name
+ folder = self.aws_folder
+ # Combine folder and prefix
+ s3prefix = f"{folder}/{prefix}" if folder else prefix
+ logger.info(f"Listing objects from S3 {actual_bucket} with prefix '{s3prefix}'")
+
+ keys = []
+ async with self.session.client("s3", config=self.boto_config) as client:
+ paginator = client.get_paginator("list_objects_v2")
+ async for page in paginator.paginate(Bucket=actual_bucket, Prefix=s3prefix):
+ if "Contents" in page:
+ for obj in page["Contents"]:
+ # Strip folder prefix from keys if present
+ key = obj["Key"]
+ if folder:
+ if key.startswith(f"{folder}/"):
+ key = key[len(folder) + 1 :]
+ elif key == folder:
+ # Skip folder marker itself
+ continue
+ keys.append(key)
+
+ return keys
+
+ @handle_s3_client_errors("stream")
+ async def _stream_to_fileobj(
+ self, filename: str, fileobj: BinaryIO, *, bucket: str | None = None
+ ):
+ """Stream file from S3 directly to file object without loading into memory."""
+ actual_bucket = bucket or self._bucket_name
+ folder = self.aws_folder
+ logger.info(f"Streaming {filename} from S3 {actual_bucket}/{folder}")
+ s3filename = f"{folder}/{filename}" if folder else filename
+ async with self.session.client("s3", config=self.boto_config) as client:
+ await client.download_fileobj(
+ Bucket=actual_bucket, Key=s3filename, Fileobj=fileobj
+ )
+
Storage.register("aws", AwsStorage)
diff --git a/server/reflector/utils/daily.py b/server/reflector/utils/daily.py
new file mode 100644
index 00000000..1c3b367c
--- /dev/null
+++ b/server/reflector/utils/daily.py
@@ -0,0 +1,26 @@
+from reflector.utils.string import NonEmptyString
+
+DailyRoomName = str
+
+
+def extract_base_room_name(daily_room_name: DailyRoomName) -> NonEmptyString:
+ """
+ Extract base room name from Daily.co timestamped room name.
+
+ Daily.co creates rooms with timestamp suffix: {base_name}-YYYYMMDDHHMMSS
+ This function removes the timestamp to get the original room name.
+
+ Examples:
+ "daily-20251020193458" → "daily"
+ "daily-2-20251020193458" → "daily-2"
+ "my-room-name-20251020193458" → "my-room-name"
+
+ Args:
+ daily_room_name: Full Daily.co room name with optional timestamp
+
+ Returns:
+ Base room name without timestamp suffix
+ """
+ base_name = daily_room_name.rsplit("-", 1)[0]
+ assert base_name, f"Extracted base name is empty from: {daily_room_name}"
+ return base_name
diff --git a/server/reflector/utils/datetime.py b/server/reflector/utils/datetime.py
new file mode 100644
index 00000000..d416412f
--- /dev/null
+++ b/server/reflector/utils/datetime.py
@@ -0,0 +1,9 @@
+from datetime import datetime, timezone
+
+
+def parse_datetime_with_timezone(iso_string: str) -> datetime:
+ """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive)."""
+ dt = datetime.fromisoformat(iso_string)
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+ return dt
diff --git a/server/reflector/utils/string.py b/server/reflector/utils/string.py
index 05f40e30..ae4277c5 100644
--- a/server/reflector/utils/string.py
+++ b/server/reflector/utils/string.py
@@ -1,4 +1,4 @@
-from typing import Annotated
+from typing import Annotated, TypeVar
from pydantic import Field, TypeAdapter, constr
@@ -21,3 +21,12 @@ def try_parse_non_empty_string(s: str) -> NonEmptyString | None:
if not s:
return None
return parse_non_empty_string(s)
+
+
+T = TypeVar("T", bound=str)
+
+
+def assert_equal[T](s1: T, s2: T) -> T:
+ if s1 != s2:
+ raise ValueError(f"assert_equal: {s1} != {s2}")
+ return s1
diff --git a/server/reflector/utils/url.py b/server/reflector/utils/url.py
new file mode 100644
index 00000000..e49a4cb0
--- /dev/null
+++ b/server/reflector/utils/url.py
@@ -0,0 +1,37 @@
+"""URL manipulation utilities."""
+
+from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
+
+
+def add_query_param(url: str, key: str, value: str) -> str:
+ """
+ Add or update a query parameter in a URL.
+
+ Properly handles URLs with or without existing query parameters,
+ preserving fragments and encoding special characters.
+
+ Args:
+ url: The URL to modify
+ key: The query parameter name
+ value: The query parameter value
+
+ Returns:
+ The URL with the query parameter added or updated
+
+ Examples:
+ >>> add_query_param("https://example.com/room", "t", "token123")
+ 'https://example.com/room?t=token123'
+
+ >>> add_query_param("https://example.com/room?existing=param", "t", "token123")
+ 'https://example.com/room?existing=param&t=token123'
+ """
+ parsed = urlparse(url)
+
+ query_params = parse_qs(parsed.query, keep_blank_values=True)
+
+ query_params[key] = [value]
+
+ new_query = urlencode(query_params, doseq=True)
+
+ new_parsed = parsed._replace(query=new_query)
+ return urlunparse(new_parsed)
diff --git a/server/reflector/video_platforms/__init__.py b/server/reflector/video_platforms/__init__.py
new file mode 100644
index 00000000..dcbdc45b
--- /dev/null
+++ b/server/reflector/video_platforms/__init__.py
@@ -0,0 +1,11 @@
+from .base import VideoPlatformClient
+from .models import MeetingData, VideoPlatformConfig
+from .registry import get_platform_client, register_platform
+
+__all__ = [
+ "VideoPlatformClient",
+ "VideoPlatformConfig",
+ "MeetingData",
+ "get_platform_client",
+ "register_platform",
+]
diff --git a/server/reflector/video_platforms/base.py b/server/reflector/video_platforms/base.py
new file mode 100644
index 00000000..d208a75a
--- /dev/null
+++ b/server/reflector/video_platforms/base.py
@@ -0,0 +1,54 @@
+from abc import ABC, abstractmethod
+from datetime import datetime
+from typing import TYPE_CHECKING, Any, Dict, List, Optional
+
+from ..schemas.platform import Platform
+from ..utils.string import NonEmptyString
+from .models import MeetingData, VideoPlatformConfig
+
+if TYPE_CHECKING:
+ from reflector.db.rooms import Room
+
+# separator doesn't guarantee there's no more "ROOM_PREFIX_SEPARATOR" strings in room name
+ROOM_PREFIX_SEPARATOR = "-"
+
+
+class VideoPlatformClient(ABC):
+ PLATFORM_NAME: Platform
+
+ def __init__(self, config: VideoPlatformConfig):
+ self.config = config
+
+ @abstractmethod
+ async def create_meeting(
+ self, room_name_prefix: NonEmptyString, end_date: datetime, room: "Room"
+ ) -> MeetingData:
+ pass
+
+ @abstractmethod
+ async def get_room_sessions(self, room_name: str) -> List[Any] | None:
+ 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
+
+ @abstractmethod
+ def verify_webhook_signature(
+ self, body: bytes, signature: str, timestamp: Optional[str] = None
+ ) -> bool:
+ pass
+
+ def format_recording_config(self, room: "Room") -> Dict[str, Any]:
+ if room.recording_type == "cloud" and self.config.s3_bucket:
+ return {
+ "type": room.recording_type,
+ "bucket": self.config.s3_bucket,
+ "region": self.config.s3_region,
+ "trigger": room.recording_trigger,
+ }
+ return {"type": room.recording_type}
diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py
new file mode 100644
index 00000000..ec45d965
--- /dev/null
+++ b/server/reflector/video_platforms/daily.py
@@ -0,0 +1,198 @@
+import base64
+import hmac
+from datetime import datetime
+from hashlib import sha256
+from http import HTTPStatus
+from typing import Any, Dict, List, Optional
+
+import httpx
+
+from reflector.db.rooms import Room
+from reflector.logger import logger
+from reflector.storage import get_dailyco_storage
+
+from ..schemas.platform import Platform
+from ..utils.daily import DailyRoomName
+from ..utils.string import NonEmptyString
+from .base import ROOM_PREFIX_SEPARATOR, VideoPlatformClient
+from .models import MeetingData, RecordingType, VideoPlatformConfig
+
+
+class DailyClient(VideoPlatformClient):
+ PLATFORM_NAME: Platform = "daily"
+ TIMEOUT = 10
+ BASE_URL = "https://api.daily.co/v1"
+ TIMESTAMP_FORMAT = "%Y%m%d%H%M%S"
+ RECORDING_NONE: RecordingType = "none"
+ RECORDING_CLOUD: RecordingType = "cloud"
+
+ def __init__(self, config: VideoPlatformConfig):
+ super().__init__(config)
+ self.headers = {
+ "Authorization": f"Bearer {config.api_key}",
+ "Content-Type": "application/json",
+ }
+
+ async def create_meeting(
+ self, room_name_prefix: NonEmptyString, end_date: datetime, room: Room
+ ) -> MeetingData:
+ """
+ Daily.co rooms vs meetings:
+ - We create a NEW Daily.co room for each Reflector meeting
+ - Daily.co meeting/session starts automatically when first participant joins
+ - Room auto-deletes after exp time
+ - Meeting.room_name stores the timestamped Daily.co room name
+ """
+ timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
+ room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{timestamp}"
+
+ data = {
+ "name": room_name,
+ "privacy": "private" if room.is_locked else "public",
+ "properties": {
+ "enable_recording": "raw-tracks"
+ if room.recording_type != self.RECORDING_NONE
+ else False,
+ "enable_chat": True,
+ "enable_screenshare": True,
+ "start_video_off": False,
+ "start_audio_off": False,
+ "exp": int(end_date.timestamp()),
+ },
+ }
+
+ # Get storage config for passing to Daily API
+ daily_storage = get_dailyco_storage()
+ assert daily_storage.bucket_name, "S3 bucket must be configured"
+ data["properties"]["recordings_bucket"] = {
+ "bucket_name": daily_storage.bucket_name,
+ "bucket_region": daily_storage.region,
+ "assume_role_arn": daily_storage.role_credential,
+ "allow_api_access": True,
+ }
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ f"{self.BASE_URL}/rooms",
+ headers=self.headers,
+ json=data,
+ timeout=self.TIMEOUT,
+ )
+ if response.status_code >= 400:
+ logger.error(
+ "Daily.co API error",
+ status_code=response.status_code,
+ response_body=response.text,
+ request_data=data,
+ )
+ response.raise_for_status()
+ result = response.json()
+
+ room_url = result["url"]
+
+ return MeetingData(
+ meeting_id=result["id"],
+ room_name=result["name"],
+ room_url=room_url,
+ host_room_url=room_url,
+ platform=self.PLATFORM_NAME,
+ extra_data=result,
+ )
+
+ async def get_room_sessions(self, room_name: str) -> List[Any] | None:
+ # no such api
+ return None
+
+ async def get_room_presence(self, room_name: str) -> Dict[str, Any]:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{self.BASE_URL}/rooms/{room_name}/presence",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json()
+
+ async def get_meeting_participants(self, meeting_id: str) -> Dict[str, Any]:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{self.BASE_URL}/meetings/{meeting_id}/participants",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json()
+
+ async def get_recording(self, recording_id: str) -> Dict[str, Any]:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{self.BASE_URL}/recordings/{recording_id}",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json()
+
+ async def delete_room(self, room_name: str) -> bool:
+ async with httpx.AsyncClient() as client:
+ response = await client.delete(
+ f"{self.BASE_URL}/rooms/{room_name}",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ return response.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
+
+ async def upload_logo(self, room_name: str, logo_path: str) -> bool:
+ return True
+
+ def verify_webhook_signature(
+ self, body: bytes, signature: str, timestamp: Optional[str] = None
+ ) -> bool:
+ """Verify Daily.co webhook signature.
+
+ Daily.co uses:
+ - X-Webhook-Signature header
+ - X-Webhook-Timestamp header
+ - Signature format: HMAC-SHA256(base64_decode(secret), timestamp + '.' + body)
+ - Result is base64 encoded
+ """
+ if not signature or not timestamp:
+ return False
+
+ try:
+ secret_bytes = base64.b64decode(self.config.webhook_secret)
+
+ signed_content = timestamp.encode() + b"." + body
+
+ expected = hmac.new(secret_bytes, signed_content, sha256).digest()
+ expected_b64 = base64.b64encode(expected).decode()
+
+ return hmac.compare_digest(expected_b64, signature)
+ except Exception as e:
+ logger.error("Daily.co webhook signature verification failed", exc_info=e)
+ return False
+
+ async def create_meeting_token(
+ self,
+ room_name: DailyRoomName,
+ enable_recording: bool,
+ user_id: Optional[str] = None,
+ ) -> str:
+ data = {"properties": {"room_name": room_name}}
+
+ if enable_recording:
+ data["properties"]["start_cloud_recording"] = True
+ data["properties"]["enable_recording_ui"] = False
+
+ if user_id:
+ data["properties"]["user_id"] = user_id
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ f"{self.BASE_URL}/meeting-tokens",
+ headers=self.headers,
+ json=data,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json()["token"]
diff --git a/server/reflector/video_platforms/factory.py b/server/reflector/video_platforms/factory.py
new file mode 100644
index 00000000..172d45e7
--- /dev/null
+++ b/server/reflector/video_platforms/factory.py
@@ -0,0 +1,62 @@
+from typing import Optional
+
+from reflector.settings import settings
+from reflector.storage import get_dailyco_storage, get_whereby_storage
+
+from ..schemas.platform import WHEREBY_PLATFORM, Platform
+from .base import VideoPlatformClient, VideoPlatformConfig
+from .registry import get_platform_client
+
+
+def get_platform_config(platform: Platform) -> VideoPlatformConfig:
+ if platform == WHEREBY_PLATFORM:
+ if not settings.WHEREBY_API_KEY:
+ raise ValueError(
+ "WHEREBY_API_KEY is required when platform='whereby'. "
+ "Set WHEREBY_API_KEY environment variable."
+ )
+ whereby_storage = get_whereby_storage()
+ key_id, secret = whereby_storage.key_credentials
+ return VideoPlatformConfig(
+ api_key=settings.WHEREBY_API_KEY,
+ webhook_secret=settings.WHEREBY_WEBHOOK_SECRET or "",
+ api_url=settings.WHEREBY_API_URL,
+ s3_bucket=whereby_storage.bucket_name,
+ s3_region=whereby_storage.region,
+ aws_access_key_id=key_id,
+ aws_access_key_secret=secret,
+ )
+ elif platform == "daily":
+ if not settings.DAILY_API_KEY:
+ raise ValueError(
+ "DAILY_API_KEY is required when platform='daily'. "
+ "Set DAILY_API_KEY environment variable."
+ )
+ if not settings.DAILY_SUBDOMAIN:
+ raise ValueError(
+ "DAILY_SUBDOMAIN is required when platform='daily'. "
+ "Set DAILY_SUBDOMAIN environment variable."
+ )
+ daily_storage = get_dailyco_storage()
+ return VideoPlatformConfig(
+ api_key=settings.DAILY_API_KEY,
+ webhook_secret=settings.DAILY_WEBHOOK_SECRET or "",
+ subdomain=settings.DAILY_SUBDOMAIN,
+ s3_bucket=daily_storage.bucket_name,
+ s3_region=daily_storage.region,
+ aws_role_arn=daily_storage.role_credential,
+ )
+ else:
+ raise ValueError(f"Unknown platform: {platform}")
+
+
+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/video_platforms/models.py b/server/reflector/video_platforms/models.py
new file mode 100644
index 00000000..82876888
--- /dev/null
+++ b/server/reflector/video_platforms/models.py
@@ -0,0 +1,40 @@
+from typing import Any, Dict, Literal, Optional
+
+from pydantic import BaseModel, Field
+
+from reflector.schemas.platform import WHEREBY_PLATFORM, Platform
+
+RecordingType = Literal["none", "local", "cloud"]
+
+
+class MeetingData(BaseModel):
+ platform: Platform
+ meeting_id: str = Field(description="Platform-specific meeting identifier")
+ room_url: str = Field(description="URL for participants to join")
+ host_room_url: str = Field(description="URL for hosts (may be same as room_url)")
+ room_name: str = Field(description="Human-readable room name")
+ extra_data: Dict[str, Any] = Field(default_factory=dict)
+
+ class Config:
+ json_schema_extra = {
+ "example": {
+ "platform": WHEREBY_PLATFORM,
+ "meeting_id": "12345678",
+ "room_url": "https://subdomain.whereby.com/room-20251008120000",
+ "host_room_url": "https://subdomain.whereby.com/room-20251008120000?roomKey=abc123",
+ "room_name": "room-20251008120000",
+ }
+ }
+
+
+class VideoPlatformConfig(BaseModel):
+ api_key: str
+ webhook_secret: str
+ api_url: Optional[str] = None
+ subdomain: Optional[str] = None # Whereby/Daily subdomain
+ s3_bucket: Optional[str] = None
+ s3_region: Optional[str] = None
+ # Whereby uses access keys, Daily uses IAM role
+ aws_access_key_id: Optional[str] = None
+ aws_access_key_secret: Optional[str] = None
+ aws_role_arn: Optional[str] = None
diff --git a/server/reflector/video_platforms/registry.py b/server/reflector/video_platforms/registry.py
new file mode 100644
index 00000000..b4c10697
--- /dev/null
+++ b/server/reflector/video_platforms/registry.py
@@ -0,0 +1,35 @@
+from typing import Dict, Type
+
+from ..schemas.platform import DAILY_PLATFORM, WHEREBY_PLATFORM, Platform
+from .base import VideoPlatformClient, VideoPlatformConfig
+
+_PLATFORMS: Dict[Platform, Type[VideoPlatformClient]] = {}
+
+
+def register_platform(name: Platform, client_class: Type[VideoPlatformClient]):
+ _PLATFORMS[name] = client_class
+
+
+def get_platform_client(
+ platform: Platform, config: VideoPlatformConfig
+) -> VideoPlatformClient:
+ if platform not in _PLATFORMS:
+ raise ValueError(f"Unknown video platform: {platform}")
+
+ client_class = _PLATFORMS[platform]
+ return client_class(config)
+
+
+def get_available_platforms() -> list[Platform]:
+ return list(_PLATFORMS.keys())
+
+
+def _register_builtin_platforms():
+ from .daily import DailyClient # noqa: PLC0415
+ from .whereby import WherebyClient # noqa: PLC0415
+
+ register_platform(WHEREBY_PLATFORM, WherebyClient)
+ register_platform(DAILY_PLATFORM, DailyClient)
+
+
+_register_builtin_platforms()
diff --git a/server/reflector/video_platforms/whereby.py b/server/reflector/video_platforms/whereby.py
new file mode 100644
index 00000000..f856454a
--- /dev/null
+++ b/server/reflector/video_platforms/whereby.py
@@ -0,0 +1,141 @@
+import hmac
+import json
+import re
+import time
+from datetime import datetime
+from hashlib import sha256
+from typing import Any, Dict, Optional
+
+import httpx
+
+from reflector.db.rooms import Room
+from reflector.storage import get_whereby_storage
+
+from ..schemas.platform import WHEREBY_PLATFORM, Platform
+from ..utils.string import NonEmptyString
+from .base import (
+ MeetingData,
+ VideoPlatformClient,
+ VideoPlatformConfig,
+)
+from .whereby_utils import whereby_room_name_prefix
+
+
+class WherebyClient(VideoPlatformClient):
+ PLATFORM_NAME: Platform = WHEREBY_PLATFORM
+ TIMEOUT = 10 # seconds
+ MAX_ELAPSED_TIME = 60 * 1000 # 1 minute in milliseconds
+
+ def __init__(self, config: VideoPlatformConfig):
+ super().__init__(config)
+ self.headers = {
+ "Content-Type": "application/json; charset=utf-8",
+ "Authorization": f"Bearer {config.api_key}",
+ }
+
+ async def create_meeting(
+ self, room_name_prefix: NonEmptyString, end_date: datetime, room: Room
+ ) -> MeetingData:
+ data = {
+ "isLocked": room.is_locked,
+ "roomNamePrefix": whereby_room_name_prefix(room_name_prefix),
+ "roomNamePattern": "uuid",
+ "roomMode": room.room_mode,
+ "endDate": end_date.isoformat(),
+ "fields": ["hostRoomUrl"],
+ }
+
+ if room.recording_type == "cloud":
+ # Get storage config for passing credentials to Whereby API
+ whereby_storage = get_whereby_storage()
+ key_id, secret = whereby_storage.key_credentials
+ data["recording"] = {
+ "type": room.recording_type,
+ "destination": {
+ "provider": "s3",
+ "bucket": whereby_storage.bucket_name,
+ "accessKeyId": key_id,
+ "accessKeySecret": secret,
+ "fileFormat": "mp4",
+ },
+ "startTrigger": room.recording_trigger,
+ }
+
+ async with httpx.AsyncClient() as client:
+ response = await client.post(
+ f"{self.config.api_url}/meetings",
+ headers=self.headers,
+ json=data,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ result = response.json()
+
+ return MeetingData(
+ meeting_id=result["meetingId"],
+ room_name=result["roomName"],
+ room_url=result["roomUrl"],
+ host_room_url=result["hostRoomUrl"],
+ platform=self.PLATFORM_NAME,
+ extra_data=result,
+ )
+
+ async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{self.config.api_url}/insights/room-sessions?roomName={room_name}",
+ headers=self.headers,
+ timeout=self.TIMEOUT,
+ )
+ response.raise_for_status()
+ return response.json().get("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:
+ response = await client.put(
+ f"{self.config.api_url}/rooms/{room_name}/theme/logo",
+ headers={
+ "Authorization": f"Bearer {self.config.api_key}",
+ },
+ timeout=self.TIMEOUT,
+ files={"image": f},
+ )
+ response.raise_for_status()
+ return True
+
+ def verify_webhook_signature(
+ self, body: bytes, signature: str, timestamp: Optional[str] = None
+ ) -> bool:
+ if not signature:
+ return False
+
+ matches = re.match(r"t=(.*),v1=(.*)", signature)
+ if not matches:
+ return False
+
+ ts, sig = matches.groups()
+
+ current_time = int(time.time() * 1000)
+ diff_time = current_time - int(ts) * 1000
+ if diff_time >= self.MAX_ELAPSED_TIME:
+ return False
+
+ body_dict = json.loads(body)
+ signed_payload = f"{ts}.{json.dumps(body_dict, separators=(',', ':'))}"
+ hmac_obj = hmac.new(
+ self.config.webhook_secret.encode("utf-8"),
+ signed_payload.encode("utf-8"),
+ sha256,
+ )
+ expected_signature = hmac_obj.hexdigest()
+
+ try:
+ return hmac.compare_digest(
+ expected_signature.encode("utf-8"), sig.encode("utf-8")
+ )
+ except Exception:
+ return False
diff --git a/server/reflector/video_platforms/whereby_utils.py b/server/reflector/video_platforms/whereby_utils.py
new file mode 100644
index 00000000..2724a7b5
--- /dev/null
+++ b/server/reflector/video_platforms/whereby_utils.py
@@ -0,0 +1,38 @@
+import re
+from datetime import datetime
+
+from reflector.utils.datetime import parse_datetime_with_timezone
+from reflector.utils.string import NonEmptyString, parse_non_empty_string
+from reflector.video_platforms.base import ROOM_PREFIX_SEPARATOR
+
+
+def parse_whereby_recording_filename(
+ object_key: NonEmptyString,
+) -> (NonEmptyString, datetime):
+ filename = parse_non_empty_string(object_key.rsplit(".", 1)[0])
+ timestamp_pattern = r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"
+ match = re.search(timestamp_pattern, filename)
+ if not match:
+ raise ValueError(f"No ISO timestamp found in filename: {object_key}")
+ timestamp_str = match.group(1)
+ timestamp_start = match.start(1)
+ room_name_part = filename[:timestamp_start]
+ if room_name_part.endswith(ROOM_PREFIX_SEPARATOR):
+ room_name_part = room_name_part[: -len(ROOM_PREFIX_SEPARATOR)]
+ else:
+ raise ValueError(
+ f"room name {room_name_part} doesnt have {ROOM_PREFIX_SEPARATOR} at the end of filename: {object_key}"
+ )
+
+ return parse_non_empty_string(room_name_part), parse_datetime_with_timezone(
+ timestamp_str
+ )
+
+
+def whereby_room_name_prefix(room_name_prefix: NonEmptyString) -> NonEmptyString:
+ return room_name_prefix + ROOM_PREFIX_SEPARATOR
+
+
+# room name comes with "/" from whereby api but lacks "/" e.g. in recording filenames
+def room_name_to_whereby_api_room_name(room_name: NonEmptyString) -> NonEmptyString:
+ return f"/{room_name}"
diff --git a/server/reflector/views/daily.py b/server/reflector/views/daily.py
new file mode 100644
index 00000000..6f51cd1e
--- /dev/null
+++ b/server/reflector/views/daily.py
@@ -0,0 +1,233 @@
+import json
+from typing import Any, Dict, Literal
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+
+from reflector.db.meetings import meetings_controller
+from reflector.logger import logger as _logger
+from reflector.settings import settings
+from reflector.utils.daily import DailyRoomName
+from reflector.video_platforms.factory import create_platform_client
+from reflector.worker.process import process_multitrack_recording
+
+router = APIRouter()
+
+logger = _logger.bind(platform="daily")
+
+
+class DailyTrack(BaseModel):
+ type: Literal["audio", "video"]
+ s3Key: str
+ size: int
+
+
+class DailyWebhookEvent(BaseModel):
+ version: str
+ type: str
+ id: str
+ payload: Dict[str, Any]
+ event_ts: float
+
+
+def _extract_room_name(event: DailyWebhookEvent) -> DailyRoomName | None:
+ """Extract room name from Daily event payload.
+
+ Daily.co API inconsistency:
+ - participant.* events use "room" field
+ - recording.* events use "room_name" field
+ """
+ return event.payload.get("room_name") or event.payload.get("room")
+
+
+@router.post("/webhook")
+async def webhook(request: Request):
+ """Handle Daily webhook events.
+
+ Daily.co circuit-breaker: After 3+ failed responses (4xx/5xx), webhook
+ state→FAILED, stops sending events. Reset: scripts/recreate_daily_webhook.py
+ """
+ body = await request.body()
+ signature = request.headers.get("X-Webhook-Signature", "")
+ timestamp = request.headers.get("X-Webhook-Timestamp", "")
+
+ client = create_platform_client("daily")
+
+ # TEMPORARY: Bypass signature check for testing
+ # TODO: Remove this after testing is complete
+ BYPASS_FOR_TESTING = True
+ if not BYPASS_FOR_TESTING:
+ if not client.verify_webhook_signature(body, signature, timestamp):
+ logger.warning(
+ "Invalid webhook signature",
+ signature=signature,
+ timestamp=timestamp,
+ has_body=bool(body),
+ )
+ raise HTTPException(status_code=401, detail="Invalid webhook signature")
+
+ try:
+ body_json = json.loads(body)
+ except json.JSONDecodeError:
+ raise HTTPException(status_code=422, detail="Invalid JSON")
+
+ if body_json.get("test") == "test":
+ logger.info("Received Daily webhook test event")
+ return {"status": "ok"}
+
+ # Parse as actual event
+ try:
+ event = DailyWebhookEvent(**body_json)
+ except Exception as e:
+ logger.error("Failed to parse webhook event", error=str(e), body=body.decode())
+ raise HTTPException(status_code=422, detail="Invalid event format")
+
+ # Handle participant events
+ if event.type == "participant.joined":
+ await _handle_participant_joined(event)
+ elif event.type == "participant.left":
+ await _handle_participant_left(event)
+ elif event.type == "recording.started":
+ await _handle_recording_started(event)
+ elif event.type == "recording.ready-to-download":
+ await _handle_recording_ready(event)
+ elif event.type == "recording.error":
+ await _handle_recording_error(event)
+ else:
+ logger.warning(
+ "Unhandled Daily webhook event type",
+ event_type=event.type,
+ payload=event.payload,
+ )
+
+ return {"status": "ok"}
+
+
+async def _handle_participant_joined(event: DailyWebhookEvent):
+ daily_room_name = _extract_room_name(event)
+ if not daily_room_name:
+ logger.warning("participant.joined: no room in payload", payload=event.payload)
+ return
+
+ meeting = await meetings_controller.get_by_room_name(daily_room_name)
+ if meeting:
+ await meetings_controller.increment_num_clients(meeting.id)
+ logger.info(
+ "Participant joined",
+ meeting_id=meeting.id,
+ room_name=daily_room_name,
+ recording_type=meeting.recording_type,
+ recording_trigger=meeting.recording_trigger,
+ )
+ else:
+ logger.warning(
+ "participant.joined: meeting not found", room_name=daily_room_name
+ )
+
+
+async def _handle_participant_left(event: DailyWebhookEvent):
+ room_name = _extract_room_name(event)
+ if not room_name:
+ return
+
+ meeting = await meetings_controller.get_by_room_name(room_name)
+ if meeting:
+ await meetings_controller.decrement_num_clients(meeting.id)
+
+
+async def _handle_recording_started(event: DailyWebhookEvent):
+ room_name = _extract_room_name(event)
+ if not room_name:
+ logger.warning(
+ "recording.started: no room_name in payload", payload=event.payload
+ )
+ return
+
+ meeting = await meetings_controller.get_by_room_name(room_name)
+ if meeting:
+ logger.info(
+ "Recording started",
+ meeting_id=meeting.id,
+ room_name=room_name,
+ recording_id=event.payload.get("recording_id"),
+ platform="daily",
+ )
+ else:
+ logger.warning("recording.started: meeting not found", room_name=room_name)
+
+
+async def _handle_recording_ready(event: DailyWebhookEvent):
+ """Handle recording ready for download event.
+
+ Daily.co webhook payload for raw-tracks recordings:
+ {
+ "recording_id": "...",
+ "room_name": "test2-20251009192341",
+ "tracks": [
+ {"type": "audio", "s3Key": "monadical/test2-.../uuid-cam-audio-123.webm", "size": 400000},
+ {"type": "video", "s3Key": "monadical/test2-.../uuid-cam-video-456.webm", "size": 30000000}
+ ]
+ }
+ """
+ room_name = _extract_room_name(event)
+ recording_id = event.payload.get("recording_id")
+ tracks_raw = event.payload.get("tracks", [])
+
+ if not room_name or not tracks_raw:
+ logger.warning(
+ "recording.ready-to-download: missing room_name or tracks",
+ room_name=room_name,
+ has_tracks=bool(tracks_raw),
+ payload=event.payload,
+ )
+ return
+
+ try:
+ tracks = [DailyTrack(**t) for t in tracks_raw]
+ except Exception as e:
+ logger.error(
+ "recording.ready-to-download: invalid tracks structure",
+ error=str(e),
+ tracks=tracks_raw,
+ )
+ return
+
+ logger.info(
+ "Recording ready for download",
+ room_name=room_name,
+ recording_id=recording_id,
+ num_tracks=len(tracks),
+ platform="daily",
+ )
+
+ bucket_name = settings.DAILYCO_STORAGE_AWS_BUCKET_NAME
+ if not bucket_name:
+ logger.error(
+ "DAILYCO_STORAGE_AWS_BUCKET_NAME not configured; cannot process Daily recording"
+ )
+ return
+
+ track_keys = [t.s3Key for t in tracks if t.type == "audio"]
+
+ process_multitrack_recording.delay(
+ bucket_name=bucket_name,
+ daily_room_name=room_name,
+ recording_id=recording_id,
+ track_keys=track_keys,
+ )
+
+
+async def _handle_recording_error(event: DailyWebhookEvent):
+ room_name = _extract_room_name(event)
+ error = event.payload.get("error", "Unknown error")
+
+ if room_name:
+ meeting = await meetings_controller.get_by_room_name(room_name)
+ if meeting:
+ logger.error(
+ "Recording error",
+ meeting_id=meeting.id,
+ room_name=room_name,
+ error=error,
+ platform="daily",
+ )
diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py
index 70e3f9e4..e786b0d9 100644
--- a/server/reflector/views/rooms.py
+++ b/server/reflector/views/rooms.py
@@ -15,9 +15,14 @@ from reflector.db.calendar_events import calendar_events_controller
from reflector.db.meetings import meetings_controller
from reflector.db.rooms import rooms_controller
from reflector.redis_cache import RedisAsyncLock
+from reflector.schemas.platform import Platform
from reflector.services.ics_sync import ics_sync_service
from reflector.settings import settings
-from reflector.whereby import create_meeting, upload_logo
+from reflector.utils.url import add_query_param
+from reflector.video_platforms.factory import (
+ create_platform_client,
+ get_platform,
+)
from reflector.worker.webhook import test_webhook
logger = logging.getLogger(__name__)
@@ -41,6 +46,7 @@ class Room(BaseModel):
ics_enabled: bool = False
ics_last_sync: Optional[datetime] = None
ics_last_etag: Optional[str] = None
+ platform: Platform
class RoomDetails(Room):
@@ -68,6 +74,7 @@ class Meeting(BaseModel):
is_active: bool = True
calendar_event_id: str | None = None
calendar_metadata: dict[str, Any] | None = None
+ platform: Platform
class CreateRoom(BaseModel):
@@ -85,6 +92,7 @@ class CreateRoom(BaseModel):
ics_url: Optional[str] = None
ics_fetch_interval: int = 300
ics_enabled: bool = False
+ platform: Optional[Platform] = None
class UpdateRoom(BaseModel):
@@ -102,6 +110,7 @@ class UpdateRoom(BaseModel):
ics_url: Optional[str] = None
ics_fetch_interval: Optional[int] = None
ics_enabled: Optional[bool] = None
+ platform: Optional[Platform] = None
class CreateRoomMeeting(BaseModel):
@@ -165,14 +174,6 @@ class CalendarEventResponse(BaseModel):
router = APIRouter()
-def parse_datetime_with_timezone(iso_string: str) -> datetime:
- """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive)."""
- dt = datetime.fromisoformat(iso_string)
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt
-
-
@router.get("/rooms", response_model=Page[RoomDetails])
async def rooms_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
@@ -182,13 +183,18 @@ async def rooms_list(
user_id = user["sub"] if user else None
- return await apaginate(
+ paginated = await apaginate(
get_database(),
await rooms_controller.get_all(
user_id=user_id, order_by="-created_at", return_query=True
),
)
+ for room in paginated.items:
+ room.platform = get_platform(room.platform)
+
+ return paginated
+
@router.get("/rooms/{room_id}", response_model=RoomDetails)
async def rooms_get(
@@ -201,6 +207,7 @@ 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
@@ -214,17 +221,16 @@ async def rooms_get_by_name(
if not room:
raise HTTPException(status_code=404, detail="Room not found")
- # Convert to RoomDetails format (add webhook fields if user is owner)
room_dict = room.__dict__.copy()
if user_id == room.user_id:
- # User is owner, include webhook details if available
room_dict["webhook_url"] = getattr(room, "webhook_url", None)
room_dict["webhook_secret"] = getattr(room, "webhook_secret", None)
else:
- # Non-owner, hide webhook details
room_dict["webhook_url"] = None
room_dict["webhook_secret"] = None
+ room_dict["platform"] = get_platform(room.platform)
+
return RoomDetails(**room_dict)
@@ -251,6 +257,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,
)
@@ -268,6 +275,7 @@ 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
@@ -315,19 +323,22 @@ async def rooms_create_meeting(
if meeting is None:
end_date = current_time + timedelta(hours=8)
- whereby_meeting = await create_meeting("", end_date=end_date, room=room)
+ platform = get_platform(room.platform)
+ client = create_platform_client(platform)
- await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
+ meeting_data = await client.create_meeting(
+ room.name, end_date=end_date, room=room
+ )
+
+ await client.upload_logo(meeting_data.room_name, "./images/logo.png")
meeting = await meetings_controller.create(
- id=whereby_meeting["meetingId"],
- room_name=whereby_meeting["roomName"],
- room_url=whereby_meeting["roomUrl"],
- host_room_url=whereby_meeting["hostRoomUrl"],
- start_date=parse_datetime_with_timezone(
- whereby_meeting["startDate"]
- ),
- end_date=parse_datetime_with_timezone(whereby_meeting["endDate"]),
+ id=meeting_data.meeting_id,
+ room_name=meeting_data.room_name,
+ room_url=meeting_data.room_url,
+ host_room_url=meeting_data.host_room_url,
+ start_date=current_time,
+ end_date=end_date,
room=room,
)
except LockError:
@@ -336,6 +347,18 @@ 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:
meeting.host_room_url = ""
@@ -490,7 +513,10 @@ async def rooms_list_active_meetings(
room=room, current_time=current_time
)
- # Hide host URLs from non-owners
+ effective_platform = get_platform(room.platform)
+ for meeting in meetings:
+ meeting.platform = effective_platform
+
if user_id != room.user_id:
for meeting in meetings:
meeting.host_room_url = ""
@@ -511,15 +537,10 @@ async def rooms_get_meeting(
if not room:
raise HTTPException(status_code=404, detail="Room not found")
- meeting = await meetings_controller.get_by_id(meeting_id)
+ meeting = await meetings_controller.get_by_id(meeting_id, room=room)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
- if meeting.room_id != room.id:
- raise HTTPException(
- status_code=403, detail="Meeting does not belong to this room"
- )
-
if user_id != room.user_id and not room.is_shared:
meeting.host_room_url = ""
@@ -538,16 +559,11 @@ async def rooms_join_meeting(
if not room:
raise HTTPException(status_code=404, detail="Room not found")
- meeting = await meetings_controller.get_by_id(meeting_id)
+ meeting = await meetings_controller.get_by_id(meeting_id, room=room)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
- if meeting.room_id != room.id:
- raise HTTPException(
- status_code=403, detail="Meeting does not belong to this room"
- )
-
if not meeting.is_active:
raise HTTPException(status_code=400, detail="Meeting is not active")
@@ -555,7 +571,6 @@ async def rooms_join_meeting(
if meeting.end_date <= current_time:
raise HTTPException(status_code=400, detail="Meeting has ended")
- # Hide host URL from non-owners
if user_id != room.user_id:
meeting.host_room_url = ""
diff --git a/server/reflector/views/transcripts_process.py b/server/reflector/views/transcripts_process.py
index f9295765..46e070fd 100644
--- a/server/reflector/views/transcripts_process.py
+++ b/server/reflector/views/transcripts_process.py
@@ -5,8 +5,12 @@ 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,
+)
router = APIRouter()
@@ -33,14 +37,35 @@ async def transcript_process(
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")
- # schedule a background task process the file
- task_pipeline_file_process.delay(transcript_id=transcript_id)
+ # 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 = list(getattr(recording, "track_keys", []) or [])
+
+ if bucket_name:
+ task_pipeline_multitrack_process.delay(
+ transcript_id=transcript_id,
+ bucket_name=bucket_name,
+ track_keys=track_keys,
+ )
+ else:
+ # Default single-file pipeline
+ task_pipeline_file_process.delay(transcript_id=transcript_id)
return ProcessStatus(status="ok")
diff --git a/server/reflector/whereby.py b/server/reflector/whereby.py
deleted file mode 100644
index 8b5c18fd..00000000
--- a/server/reflector/whereby.py
+++ /dev/null
@@ -1,114 +0,0 @@
-import logging
-from datetime import datetime
-
-import httpx
-
-from reflector.db.rooms import Room
-from reflector.settings import settings
-from reflector.utils.string import parse_non_empty_string
-
-logger = logging.getLogger(__name__)
-
-
-def _get_headers():
- api_key = parse_non_empty_string(
- settings.WHEREBY_API_KEY, "WHEREBY_API_KEY value is required."
- )
- return {
- "Content-Type": "application/json; charset=utf-8",
- "Authorization": f"Bearer {api_key}",
- }
-
-
-TIMEOUT = 10 # seconds
-
-
-def _get_whereby_s3_auth():
- errors = []
- try:
- bucket_name = parse_non_empty_string(
- settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
- "RECORDING_STORAGE_AWS_BUCKET_NAME value is required.",
- )
- except Exception as e:
- errors.append(e)
- try:
- key_id = parse_non_empty_string(
- settings.AWS_WHEREBY_ACCESS_KEY_ID,
- "AWS_WHEREBY_ACCESS_KEY_ID value is required.",
- )
- except Exception as e:
- errors.append(e)
- try:
- key_secret = parse_non_empty_string(
- settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
- "AWS_WHEREBY_ACCESS_KEY_SECRET value is required.",
- )
- except Exception as e:
- errors.append(e)
- if len(errors) > 0:
- raise Exception(
- f"Failed to get Whereby auth settings: {', '.join(str(e) for e in errors)}"
- )
- return bucket_name, key_id, key_secret
-
-
-async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
- s3_bucket_name, s3_key_id, s3_key_secret = _get_whereby_s3_auth()
- data = {
- "isLocked": room.is_locked,
- "roomNamePrefix": room_name_prefix,
- "roomNamePattern": "uuid",
- "roomMode": room.room_mode,
- "endDate": end_date.isoformat(),
- "recording": {
- "type": room.recording_type,
- "destination": {
- "provider": "s3",
- "bucket": s3_bucket_name,
- "accessKeyId": s3_key_id,
- "accessKeySecret": s3_key_secret,
- "fileFormat": "mp4",
- },
- "startTrigger": room.recording_trigger,
- },
- "fields": ["hostRoomUrl"],
- }
- async with httpx.AsyncClient() as client:
- response = await client.post(
- f"{settings.WHEREBY_API_URL}/meetings",
- headers=_get_headers(),
- json=data,
- timeout=TIMEOUT,
- )
- if response.status_code == 403:
- logger.warning(
- f"Failed to create meeting: access denied on Whereby: {response.text}"
- )
- response.raise_for_status()
- return response.json()
-
-
-async def get_room_sessions(room_name: str):
- async with httpx.AsyncClient() as client:
- response = await client.get(
- f"{settings.WHEREBY_API_URL}/insights/room-sessions?roomName={room_name}",
- headers=_get_headers(),
- timeout=TIMEOUT,
- )
- response.raise_for_status()
- return response.json()
-
-
-async def upload_logo(room_name: str, logo_path: str):
- async with httpx.AsyncClient() as client:
- with open(logo_path, "rb") as f:
- response = await client.put(
- f"{settings.WHEREBY_API_URL}/rooms{room_name}/theme/logo",
- headers={
- "Authorization": f"Bearer {settings.WHEREBY_API_KEY}",
- },
- timeout=TIMEOUT,
- files={"image": f},
- )
- response.raise_for_status()
diff --git a/server/reflector/worker/cleanup.py b/server/reflector/worker/cleanup.py
index 66d45e94..43559e64 100644
--- a/server/reflector/worker/cleanup.py
+++ b/server/reflector/worker/cleanup.py
@@ -19,7 +19,7 @@ from reflector.db.meetings import meetings
from reflector.db.recordings import recordings
from reflector.db.transcripts import transcripts, transcripts_controller
from reflector.settings import settings
-from reflector.storage import get_recordings_storage
+from reflector.storage import get_transcripts_storage
logger = structlog.get_logger(__name__)
@@ -53,8 +53,8 @@ async def delete_single_transcript(
)
if recording:
try:
- await get_recordings_storage().delete_file(
- recording["object_key"]
+ await get_transcripts_storage().delete_file(
+ recording["object_key"], bucket=recording["bucket_name"]
)
except Exception as storage_error:
logger.warning(
diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py
index faf62f4a..4d72d4ae 100644
--- a/server/reflector/worker/ics_sync.py
+++ b/server/reflector/worker/ics_sync.py
@@ -7,10 +7,10 @@ from celery.utils.log import get_task_logger
from reflector.asynctask import asynctask
from reflector.db.calendar_events import calendar_events_controller
from reflector.db.meetings import meetings_controller
-from reflector.db.rooms import rooms_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.whereby import create_meeting, upload_logo
+from reflector.video_platforms.factory import create_platform_client, get_platform
logger = structlog.wrap_logger(get_task_logger(__name__))
@@ -86,17 +86,17 @@ def _should_sync(room) -> bool:
MEETING_DEFAULT_DURATION = timedelta(hours=1)
-async def create_upcoming_meetings_for_event(event, create_window, room_id, room):
+async def create_upcoming_meetings_for_event(event, create_window, room: Room):
if event.start_time <= create_window:
return
- existing_meeting = await meetings_controller.get_by_calendar_event(event.id)
+ existing_meeting = await meetings_controller.get_by_calendar_event(event.id, room)
if existing_meeting:
return
logger.info(
"Pre-creating meeting for calendar event",
- room_id=room_id,
+ room_id=room.id,
event_id=event.id,
event_title=event.title,
)
@@ -104,20 +104,22 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room
try:
end_date = event.end_time or (event.start_time + MEETING_DEFAULT_DURATION)
- whereby_meeting = await create_meeting(
+ client = create_platform_client(get_platform(room.platform))
+
+ meeting_data = await client.create_meeting(
"",
end_date=end_date,
room=room,
)
- await upload_logo(whereby_meeting["roomName"], "./images/logo.png")
+ await client.upload_logo(meeting_data.room_name, "./images/logo.png")
meeting = await meetings_controller.create(
- id=whereby_meeting["meetingId"],
- room_name=whereby_meeting["roomName"],
- room_url=whereby_meeting["roomUrl"],
- host_room_url=whereby_meeting["hostRoomUrl"],
- start_date=datetime.fromisoformat(whereby_meeting["startDate"]),
- end_date=datetime.fromisoformat(whereby_meeting["endDate"]),
+ id=meeting_data.meeting_id,
+ room_name=meeting_data.room_name,
+ room_url=meeting_data.room_url,
+ host_room_url=meeting_data.host_room_url,
+ start_date=event.start_time,
+ end_date=end_date,
room=room,
calendar_event_id=event.id,
calendar_metadata={
@@ -136,7 +138,7 @@ async def create_upcoming_meetings_for_event(event, create_window, room_id, room
except Exception as e:
logger.error(
"Failed to pre-create meeting",
- room_id=room_id,
+ room_id=room.id,
event_id=event.id,
error=str(e),
)
@@ -166,9 +168,7 @@ async def create_upcoming_meetings():
)
for event in events:
- await create_upcoming_meetings_for_event(
- event, create_window, room.id, room
- )
+ await create_upcoming_meetings_for_event(event, create_window, room)
logger.info("Completed pre-creation check for upcoming meetings")
except Exception as e:
diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py
index e660e840..47cbb1cb 100644
--- a/server/reflector/worker/process.py
+++ b/server/reflector/worker/process.py
@@ -1,5 +1,6 @@
import json
import os
+import re
from datetime import datetime, timezone
from urllib.parse import unquote
@@ -14,24 +15,32 @@ from redis.exceptions import LockError
from reflector.db.meetings import meetings_controller
from reflector.db.recordings import Recording, recordings_controller
from reflector.db.rooms import rooms_controller
-from reflector.db.transcripts import SourceKind, transcripts_controller
+from reflector.db.transcripts import (
+ SourceKind,
+ TranscriptParticipant,
+ transcripts_controller,
+)
from reflector.pipelines.main_file_pipeline import task_pipeline_file_process
from reflector.pipelines.main_live_pipeline import asynctask
+from reflector.pipelines.main_multitrack_pipeline import (
+ task_pipeline_multitrack_process,
+)
+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.settings import settings
-from reflector.whereby import get_room_sessions
+from reflector.storage import get_transcripts_storage
+from reflector.utils.daily import DailyRoomName, extract_base_room_name
+from reflector.video_platforms.factory import create_platform_client
+from reflector.video_platforms.whereby_utils import (
+ parse_whereby_recording_filename,
+ room_name_to_whereby_api_room_name,
+)
logger = structlog.wrap_logger(get_task_logger(__name__))
-def parse_datetime_with_timezone(iso_string: str) -> datetime:
- """Parse ISO datetime string and ensure timezone awareness (defaults to UTC if naive)."""
- dt = datetime.fromisoformat(iso_string)
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt
-
-
@shared_task
def process_messages():
queue_url = settings.AWS_PROCESS_RECORDING_QUEUE_URL
@@ -73,14 +82,16 @@ def process_messages():
logger.error("process_messages", error=str(e))
+# only whereby supported.
@shared_task
@asynctask
async def process_recording(bucket_name: str, object_key: str):
logger.info("Processing recording: %s/%s", bucket_name, object_key)
- # extract a guid and a datetime from the object key
- room_name = f"/{object_key[:36]}"
- recorded_at = parse_datetime_with_timezone(object_key[37:57])
+ room_name_part, recorded_at = parse_whereby_recording_filename(object_key)
+
+ # we store whereby api room names, NOT whereby room names
+ room_name = room_name_to_whereby_api_room_name(room_name_part)
meeting = await meetings_controller.get_by_room_name(room_name)
room = await rooms_controller.get_by_id(meeting.room_id)
@@ -102,6 +113,7 @@ async def process_recording(bucket_name: str, object_key: str):
transcript,
{
"topics": [],
+ "participants": [],
},
)
else:
@@ -121,15 +133,15 @@ async def process_recording(bucket_name: str, object_key: str):
upload_filename = transcript.data_path / f"upload{extension}"
upload_filename.parent.mkdir(parents=True, exist_ok=True)
- s3 = boto3.client(
- "s3",
- region_name=settings.TRANSCRIPT_STORAGE_AWS_REGION,
- aws_access_key_id=settings.TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID,
- aws_secret_access_key=settings.TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY,
- )
+ storage = get_transcripts_storage()
- with open(upload_filename, "wb") as f:
- s3.download_fileobj(bucket_name, object_key, f)
+ try:
+ with open(upload_filename, "wb") as f:
+ await storage.stream_to_fileobj(object_key, f, bucket=bucket_name)
+ except Exception:
+ # Clean up partial file on stream failure
+ upload_filename.unlink(missing_ok=True)
+ raise
container = av.open(upload_filename.as_posix())
try:
@@ -146,6 +158,165 @@ async def process_recording(bucket_name: str, object_key: str):
task_pipeline_file_process.delay(transcript_id=transcript.id)
+@shared_task
+@asynctask
+async def process_multitrack_recording(
+ bucket_name: str,
+ daily_room_name: DailyRoomName,
+ recording_id: str,
+ track_keys: list[str],
+):
+ logger.info(
+ "Processing multitrack recording",
+ bucket=bucket_name,
+ room_name=daily_room_name,
+ recording_id=recording_id,
+ provided_keys=len(track_keys),
+ )
+
+ if not track_keys:
+ logger.warning("No audio track keys provided")
+ return
+
+ tz = timezone.utc
+ recorded_at = datetime.now(tz)
+ try:
+ if track_keys:
+ folder = os.path.basename(os.path.dirname(track_keys[0]))
+ ts_match = re.search(r"(\d{14})$", folder)
+ if ts_match:
+ ts = ts_match.group(1)
+ recorded_at = datetime.strptime(ts, "%Y%m%d%H%M%S").replace(tzinfo=tz)
+ except Exception as e:
+ logger.warning(
+ f"Could not parse recorded_at from keys, using now() {recorded_at}",
+ e,
+ exc_info=True,
+ )
+
+ meeting = await meetings_controller.get_by_room_name(daily_room_name)
+
+ room_name_base = extract_base_room_name(daily_room_name)
+
+ room = await rooms_controller.get_by_name(room_name_base)
+ if not room:
+ raise Exception(f"Room not found: {room_name_base}")
+
+ if not meeting:
+ raise Exception(f"Meeting not found: {room_name_base}")
+
+ logger.info(
+ "Found existing Meeting for recording",
+ meeting_id=meeting.id,
+ room_name=daily_room_name,
+ recording_id=recording_id,
+ )
+
+ recording = await recordings_controller.get_by_id(recording_id)
+ if not recording:
+ object_key_dir = os.path.dirname(track_keys[0]) if track_keys else ""
+ recording = await recordings_controller.create(
+ Recording(
+ id=recording_id,
+ bucket_name=bucket_name,
+ object_key=object_key_dir,
+ recorded_at=recorded_at,
+ meeting_id=meeting.id,
+ track_keys=track_keys,
+ )
+ )
+ else:
+ # Recording already exists; assume metadata was set at creation time
+ pass
+
+ transcript = await transcripts_controller.get_by_recording_id(recording.id)
+ if transcript:
+ await transcripts_controller.update(
+ transcript,
+ {
+ "topics": [],
+ "participants": [],
+ },
+ )
+ else:
+ transcript = await transcripts_controller.add(
+ "",
+ source_kind=SourceKind.ROOM,
+ source_language="en",
+ target_language="en",
+ user_id=room.user_id,
+ recording_id=recording.id,
+ share_mode="public",
+ meeting_id=meeting.id,
+ room_id=room.id,
+ )
+
+ try:
+ daily_client = create_platform_client("daily")
+
+ 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
+ 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,
+ )
+
+ 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
+
+ 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,
+ )
+
+
@shared_task
@asynctask
async def process_meetings():
@@ -164,7 +335,7 @@ async def process_meetings():
Uses distributed locking to prevent race conditions when multiple workers
process the same meeting simultaneously.
"""
- logger.info("Processing meetings")
+ logger.debug("Processing meetings")
meetings = await meetings_controller.get_all_active()
current_time = datetime.now(timezone.utc)
redis_client = get_redis_client()
@@ -189,7 +360,8 @@ async def process_meetings():
end_date = end_date.replace(tzinfo=timezone.utc)
# This API call could be slow, extend lock if needed
- response = await 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 slow operation to ensure we still hold it
@@ -198,7 +370,6 @@ async def process_meetings():
logger_.warning("Lost lock for meeting, skipping")
continue
- room_sessions = response.get("results", [])
has_active_sessions = room_sessions and any(
rs["endedAt"] is None for rs in room_sessions
)
@@ -231,69 +402,120 @@ async def process_meetings():
except LockError:
pass # Lock already released or expired
- logger.info(
+ logger.debug(
"Processed meetings finished",
processed_count=processed_count,
skipped_count=skipped_count,
)
+async def convert_audio_and_waveform(transcript) -> None:
+ """Convert WebM to MP3 and generate waveform for Daily.co recordings.
+
+ This bypasses the full file pipeline which would overwrite stub data.
+ """
+ try:
+ logger.info(
+ "Converting audio to MP3 and generating waveform",
+ transcript_id=transcript.id,
+ )
+
+ upload_path = transcript.data_path / "upload.webm"
+ mp3_path = transcript.audio_mp3_filename
+
+ # Convert WebM to MP3
+ mp3_writer = AudioFileWriterProcessor(path=mp3_path)
+
+ container = av.open(str(upload_path))
+ for frame in container.decode(audio=0):
+ await mp3_writer.push(frame)
+ await mp3_writer.flush()
+ container.close()
+
+ logger.info(
+ "Converted WebM to MP3",
+ transcript_id=transcript.id,
+ mp3_size=mp3_path.stat().st_size,
+ )
+
+ waveform_processor = AudioWaveformProcessor(
+ audio_path=mp3_path,
+ waveform_path=transcript.audio_waveform_filename,
+ )
+ waveform_processor.set_pipeline(EmptyPipeline(logger))
+ await waveform_processor.flush()
+
+ logger.info(
+ "Generated waveform",
+ transcript_id=transcript.id,
+ waveform_path=transcript.audio_waveform_filename,
+ )
+
+ # Update transcript status to ended (successful)
+ await transcripts_controller.update(transcript, {"status": "ended"})
+
+ except Exception as e:
+ logger.error(
+ "Failed to convert audio or generate waveform",
+ transcript_id=transcript.id,
+ error=str(e),
+ )
+ # Keep status as uploaded even if conversion fails
+ pass
+
+
@shared_task
@asynctask
async def reprocess_failed_recordings():
"""
- Find recordings in the S3 bucket and check if they have proper transcriptions.
+ Find recordings in Whereby S3 bucket and check if they have proper transcriptions.
If not, requeue them for processing.
- """
- logger.info("Checking for recordings that need processing or reprocessing")
- s3 = boto3.client(
- "s3",
- region_name=settings.TRANSCRIPT_STORAGE_AWS_REGION,
- aws_access_key_id=settings.TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID,
- aws_secret_access_key=settings.TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY,
- )
+ Note: Daily.co recordings are processed via webhooks, not this cron job.
+ """
+ logger.info("Checking Whereby recordings that need processing or reprocessing")
+
+ if not settings.WHEREBY_STORAGE_AWS_BUCKET_NAME:
+ raise ValueError(
+ "WHEREBY_STORAGE_AWS_BUCKET_NAME required for Whereby recording reprocessing. "
+ "Set WHEREBY_STORAGE_AWS_BUCKET_NAME environment variable."
+ )
+
+ storage = get_transcripts_storage()
+ bucket_name = settings.WHEREBY_STORAGE_AWS_BUCKET_NAME
reprocessed_count = 0
try:
- paginator = s3.get_paginator("list_objects_v2")
- bucket_name = settings.RECORDING_STORAGE_AWS_BUCKET_NAME
- pages = paginator.paginate(Bucket=bucket_name)
+ object_keys = await storage.list_objects(prefix="", bucket=bucket_name)
- for page in pages:
- if "Contents" not in page:
+ for object_key in object_keys:
+ if not object_key.endswith(".mp4"):
continue
- for obj in page["Contents"]:
- object_key = obj["Key"]
+ recording = await recordings_controller.get_by_object_key(
+ bucket_name, object_key
+ )
+ if not recording:
+ logger.info(f"Queueing recording for processing: {object_key}")
+ process_recording.delay(bucket_name, object_key)
+ reprocessed_count += 1
+ continue
- if not (object_key.endswith(".mp4")):
- continue
-
- recording = await recordings_controller.get_by_object_key(
- bucket_name, object_key
+ transcript = None
+ try:
+ transcript = await transcripts_controller.get_by_recording_id(
+ recording.id
+ )
+ except ValidationError:
+ await transcripts_controller.remove_by_recording_id(recording.id)
+ logger.warning(
+ f"Removed invalid transcript for recording: {recording.id}"
)
- if not recording:
- logger.info(f"Queueing recording for processing: {object_key}")
- process_recording.delay(bucket_name, object_key)
- reprocessed_count += 1
- continue
- transcript = None
- try:
- transcript = await transcripts_controller.get_by_recording_id(
- recording.id
- )
- except ValidationError:
- await transcripts_controller.remove_by_recording_id(recording.id)
- logger.warning(
- f"Removed invalid transcript for recording: {recording.id}"
- )
-
- if transcript is None or transcript.status == "error":
- logger.info(f"Queueing recording for processing: {object_key}")
- process_recording.delay(bucket_name, object_key)
- reprocessed_count += 1
+ if transcript is None or transcript.status == "error":
+ logger.info(f"Queueing recording for processing: {object_key}")
+ process_recording.delay(bucket_name, object_key)
+ reprocessed_count += 1
except Exception as e:
logger.error(f"Error checking S3 bucket: {str(e)}")
diff --git a/server/scripts/recreate_daily_webhook.py b/server/scripts/recreate_daily_webhook.py
new file mode 100644
index 00000000..a378baf2
--- /dev/null
+++ b/server/scripts/recreate_daily_webhook.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+import asyncio
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+import httpx
+
+from reflector.settings import settings
+
+
+async def setup_webhook(webhook_url: str):
+ """
+ Create or update Daily.co webhook for this environment.
+ Uses DAILY_WEBHOOK_UUID to identify existing webhook.
+ """
+ if not settings.DAILY_API_KEY:
+ print("Error: DAILY_API_KEY not set")
+ return 1
+
+ headers = {
+ "Authorization": f"Bearer {settings.DAILY_API_KEY}",
+ "Content-Type": "application/json",
+ }
+
+ webhook_data = {
+ "url": webhook_url,
+ "eventTypes": [
+ "participant.joined",
+ "participant.left",
+ "recording.started",
+ "recording.ready-to-download",
+ "recording.error",
+ ],
+ "hmac": settings.DAILY_WEBHOOK_SECRET,
+ }
+
+ async with httpx.AsyncClient() as client:
+ webhook_uuid = settings.DAILY_WEBHOOK_UUID
+
+ if webhook_uuid:
+ # Update existing webhook
+ print(f"Updating existing webhook {webhook_uuid}...")
+ try:
+ resp = await client.patch(
+ f"https://api.daily.co/v1/webhooks/{webhook_uuid}",
+ headers=headers,
+ json=webhook_data,
+ )
+ resp.raise_for_status()
+ result = resp.json()
+ print(f"✓ Updated webhook {result['uuid']} (state: {result['state']})")
+ print(f" URL: {result['url']}")
+ return 0
+ except httpx.HTTPStatusError as e:
+ if e.response.status_code == 404:
+ print(f"Webhook {webhook_uuid} not found, creating new one...")
+ webhook_uuid = None # Fall through to creation
+ else:
+ print(f"Error updating webhook: {e}")
+ return 1
+
+ if not webhook_uuid:
+ # Create new webhook
+ print("Creating new webhook...")
+ resp = await client.post(
+ "https://api.daily.co/v1/webhooks", headers=headers, json=webhook_data
+ )
+ resp.raise_for_status()
+ result = resp.json()
+ webhook_uuid = result["uuid"]
+
+ print(f"✓ Created webhook {webhook_uuid} (state: {result['state']})")
+ print(f" URL: {result['url']}")
+ print()
+ print("=" * 60)
+ print("IMPORTANT: Add this to your environment variables:")
+ print("=" * 60)
+ print(f"DAILY_WEBHOOK_UUID: {webhook_uuid}")
+ print("=" * 60)
+ print()
+
+ # Try to write UUID to .env file
+ env_file = Path(__file__).parent.parent / ".env"
+ if env_file.exists():
+ lines = env_file.read_text().splitlines()
+ updated = False
+
+ # Update existing DAILY_WEBHOOK_UUID line or add it
+ for i, line in enumerate(lines):
+ if line.startswith("DAILY_WEBHOOK_UUID="):
+ lines[i] = f"DAILY_WEBHOOK_UUID={webhook_uuid}"
+ updated = True
+ break
+
+ if not updated:
+ lines.append(f"DAILY_WEBHOOK_UUID={webhook_uuid}")
+
+ env_file.write_text("\n".join(lines) + "\n")
+ print(f"✓ Also saved to local .env file")
+ else:
+ print(f"⚠ Local .env file not found - please add manually")
+
+ return 0
+
+
+if __name__ == "__main__":
+ if len(sys.argv) != 2:
+ print("Usage: python recreate_daily_webhook.py ")
+ print(
+ "Example: python recreate_daily_webhook.py https://example.com/v1/daily/webhook"
+ )
+ print()
+ print("Behavior:")
+ print(" - If DAILY_WEBHOOK_UUID set: Updates existing webhook")
+ print(
+ " - If DAILY_WEBHOOK_UUID empty: Creates new webhook, saves UUID to .env"
+ )
+ sys.exit(1)
+
+ sys.exit(asyncio.run(setup_webhook(sys.argv[1])))
diff --git a/server/tests/conftest.py b/server/tests/conftest.py
index a70604ae..7d6c4302 100644
--- a/server/tests/conftest.py
+++ b/server/tests/conftest.py
@@ -5,6 +5,18 @@ from unittest.mock import patch
import pytest
+from reflector.schemas.platform import WHEREBY_PLATFORM
+
+
+@pytest.fixture(scope="session", autouse=True)
+def register_mock_platform():
+ from mocks.mock_platform import MockPlatformClient
+
+ from reflector.video_platforms.registry import register_platform
+
+ register_platform(WHEREBY_PLATFORM, MockPlatformClient)
+ yield
+
@pytest.fixture(scope="session", autouse=True)
def settings_configuration():
diff --git a/server/tests/mocks/__init__.py b/server/tests/mocks/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/tests/mocks/mock_platform.py b/server/tests/mocks/mock_platform.py
new file mode 100644
index 00000000..0f84a271
--- /dev/null
+++ b/server/tests/mocks/mock_platform.py
@@ -0,0 +1,112 @@
+import uuid
+from datetime import datetime
+from typing import Any, Dict, Literal, Optional
+
+from reflector.db.rooms import Room
+from reflector.video_platforms.base import (
+ ROOM_PREFIX_SEPARATOR,
+ MeetingData,
+ VideoPlatformClient,
+ VideoPlatformConfig,
+)
+
+MockPlatform = Literal["mock"]
+
+
+class MockPlatformClient(VideoPlatformClient):
+ PLATFORM_NAME: MockPlatform = "mock"
+
+ def __init__(self, config: VideoPlatformConfig):
+ super().__init__(config)
+ self._rooms: Dict[str, Dict[str, Any]] = {}
+ self._webhook_calls: list[Dict[str, Any]] = []
+
+ async def create_meeting(
+ self, room_name_prefix: str, end_date: datetime, room: Room
+ ) -> MeetingData:
+ meeting_id = str(uuid.uuid4())
+ room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{meeting_id[:8]}"
+ room_url = f"https://mock.video/{room_name}"
+ host_room_url = f"{room_url}?host=true"
+
+ self._rooms[room_name] = {
+ "id": meeting_id,
+ "name": room_name,
+ "url": room_url,
+ "host_url": host_room_url,
+ "end_date": end_date,
+ "room": room,
+ "participants": [],
+ "is_active": True,
+ }
+
+ return MeetingData.model_construct(
+ meeting_id=meeting_id,
+ room_name=room_name,
+ room_url=room_url,
+ host_room_url=host_room_url,
+ platform="whereby",
+ extra_data={"mock": True},
+ )
+
+ async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
+ if room_name not in self._rooms:
+ return {"error": "Room not found"}
+
+ room_data = self._rooms[room_name]
+ return {
+ "roomName": room_name,
+ "sessions": [
+ {
+ "sessionId": room_data["id"],
+ "startTime": datetime.utcnow().isoformat(),
+ "participants": room_data["participants"],
+ "isActive": room_data["is_active"],
+ }
+ ],
+ }
+
+ 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
+ return True
+ return False
+
+ def verify_webhook_signature(
+ self, body: bytes, signature: str, timestamp: Optional[str] = None
+ ) -> bool:
+ return signature == "valid"
+
+ def add_participant(
+ self, room_name: str, participant_id: str, participant_name: str
+ ):
+ if room_name in self._rooms:
+ self._rooms[room_name]["participants"].append(
+ {
+ "id": participant_id,
+ "name": participant_name,
+ "joined_at": datetime.utcnow().isoformat(),
+ }
+ )
+
+ def trigger_webhook(self, event_type: str, data: Dict[str, Any]):
+ self._webhook_calls.append(
+ {
+ "type": event_type,
+ "data": data,
+ "timestamp": datetime.utcnow().isoformat(),
+ }
+ )
+
+ def get_webhook_calls(self) -> list[Dict[str, Any]]:
+ return self._webhook_calls.copy()
+
+ def clear_data(self):
+ self._rooms.clear()
+ self._webhook_calls.clear()
diff --git a/server/tests/test_cleanup.py b/server/tests/test_cleanup.py
index 2cb8614c..0c968941 100644
--- a/server/tests/test_cleanup.py
+++ b/server/tests/test_cleanup.py
@@ -139,14 +139,10 @@ async def test_cleanup_deletes_associated_meeting_and_recording():
mock_settings.PUBLIC_DATA_RETENTION_DAYS = 7
# Mock storage deletion
- with patch("reflector.db.transcripts.get_transcripts_storage") as mock_storage:
+ with patch("reflector.worker.cleanup.get_transcripts_storage") as mock_storage:
mock_storage.return_value.delete_file = AsyncMock()
- with patch(
- "reflector.worker.cleanup.get_recordings_storage"
- ) as mock_rec_storage:
- mock_rec_storage.return_value.delete_file = AsyncMock()
- result = await cleanup_old_public_data()
+ result = await cleanup_old_public_data()
# Check results
assert result["transcripts_deleted"] == 1
diff --git a/server/tests/test_consent_multitrack.py b/server/tests/test_consent_multitrack.py
new file mode 100644
index 00000000..15948708
--- /dev/null
+++ b/server/tests/test_consent_multitrack.py
@@ -0,0 +1,330 @@
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from reflector.db.meetings import (
+ MeetingConsent,
+ meeting_consent_controller,
+ meetings_controller,
+)
+from reflector.db.recordings import Recording, recordings_controller
+from reflector.db.rooms import rooms_controller
+from reflector.db.transcripts import SourceKind, transcripts_controller
+from reflector.pipelines.main_live_pipeline import cleanup_consent
+
+
+@pytest.mark.asyncio
+async def test_consent_cleanup_deletes_multitrack_files():
+ room = await rooms_controller.add(
+ name="Test Room",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic",
+ is_shared=False,
+ platform="daily",
+ )
+
+ # Create meeting
+ meeting = await meetings_controller.create(
+ id="test-multitrack-meeting",
+ room_name="test-room-20250101120000",
+ room_url="https://test.daily.co/test-room",
+ host_room_url="https://test.daily.co/test-room",
+ start_date=datetime.now(timezone.utc),
+ end_date=datetime.now(timezone.utc),
+ room=room,
+ )
+
+ track_keys = [
+ "recordings/test-room-20250101120000/track-0.webm",
+ "recordings/test-room-20250101120000/track-1.webm",
+ "recordings/test-room-20250101120000/track-2.webm",
+ ]
+ recording = await recordings_controller.create(
+ Recording(
+ bucket_name="test-bucket",
+ object_key="recordings/test-room-20250101120000", # Folder path
+ recorded_at=datetime.now(timezone.utc),
+ meeting_id=meeting.id,
+ track_keys=track_keys,
+ )
+ )
+
+ # Create transcript
+ transcript = await transcripts_controller.add(
+ name="Test Multitrack Transcript",
+ source_kind=SourceKind.ROOM,
+ recording_id=recording.id,
+ meeting_id=meeting.id,
+ )
+
+ # Add consent denial
+ await meeting_consent_controller.upsert(
+ MeetingConsent(
+ meeting_id=meeting.id,
+ user_id="test-user",
+ consent_given=False,
+ consent_timestamp=datetime.now(timezone.utc),
+ )
+ )
+
+ # Mock get_transcripts_storage (master credentials with bucket override)
+ with patch(
+ "reflector.pipelines.main_live_pipeline.get_transcripts_storage"
+ ) as mock_get_transcripts_storage:
+ mock_master_storage = MagicMock()
+ mock_master_storage.delete_file = AsyncMock()
+ mock_get_transcripts_storage.return_value = mock_master_storage
+
+ await cleanup_consent(transcript_id=transcript.id)
+
+ # Verify master storage was used with bucket override for all track keys
+ assert mock_master_storage.delete_file.call_count == 3
+ deleted_keys = []
+ for call_args in mock_master_storage.delete_file.call_args_list:
+ key = call_args[0][0]
+ bucket_kwarg = call_args[1].get("bucket")
+ deleted_keys.append(key)
+ assert bucket_kwarg == "test-bucket" # Verify bucket override!
+ assert set(deleted_keys) == set(track_keys)
+
+ updated_transcript = await transcripts_controller.get_by_id(transcript.id)
+ assert updated_transcript.audio_deleted is True
+
+
+@pytest.mark.asyncio
+async def test_consent_cleanup_handles_missing_track_keys():
+ room = await rooms_controller.add(
+ name="Test Room 2",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic",
+ is_shared=False,
+ platform="daily",
+ )
+
+ # Create meeting
+ meeting = await meetings_controller.create(
+ id="test-multitrack-meeting-2",
+ room_name="test-room-20250101120001",
+ room_url="https://test.daily.co/test-room-2",
+ host_room_url="https://test.daily.co/test-room-2",
+ start_date=datetime.now(timezone.utc),
+ end_date=datetime.now(timezone.utc),
+ room=room,
+ )
+
+ recording = await recordings_controller.create(
+ Recording(
+ bucket_name="test-bucket",
+ object_key="recordings/old-style-recording.mp4",
+ recorded_at=datetime.now(timezone.utc),
+ meeting_id=meeting.id,
+ track_keys=None,
+ )
+ )
+
+ transcript = await transcripts_controller.add(
+ name="Test Old-Style Transcript",
+ source_kind=SourceKind.ROOM,
+ recording_id=recording.id,
+ meeting_id=meeting.id,
+ )
+
+ # Add consent denial
+ await meeting_consent_controller.upsert(
+ MeetingConsent(
+ meeting_id=meeting.id,
+ user_id="test-user-2",
+ consent_given=False,
+ consent_timestamp=datetime.now(timezone.utc),
+ )
+ )
+
+ # Mock get_transcripts_storage (master credentials with bucket override)
+ with patch(
+ "reflector.pipelines.main_live_pipeline.get_transcripts_storage"
+ ) as mock_get_transcripts_storage:
+ mock_master_storage = MagicMock()
+ mock_master_storage.delete_file = AsyncMock()
+ mock_get_transcripts_storage.return_value = mock_master_storage
+
+ await cleanup_consent(transcript_id=transcript.id)
+
+ # Verify master storage was used with bucket override
+ assert mock_master_storage.delete_file.call_count == 1
+ call_args = mock_master_storage.delete_file.call_args
+ assert call_args[0][0] == recording.object_key
+ assert call_args[1].get("bucket") == "test-bucket" # Verify bucket override!
+
+
+@pytest.mark.asyncio
+async def test_consent_cleanup_empty_track_keys_falls_back():
+ room = await rooms_controller.add(
+ name="Test Room 3",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic",
+ is_shared=False,
+ platform="daily",
+ )
+
+ # Create meeting
+ meeting = await meetings_controller.create(
+ id="test-multitrack-meeting-3",
+ room_name="test-room-20250101120002",
+ room_url="https://test.daily.co/test-room-3",
+ host_room_url="https://test.daily.co/test-room-3",
+ start_date=datetime.now(timezone.utc),
+ end_date=datetime.now(timezone.utc),
+ room=room,
+ )
+
+ recording = await recordings_controller.create(
+ Recording(
+ bucket_name="test-bucket",
+ object_key="recordings/fallback-recording.mp4",
+ recorded_at=datetime.now(timezone.utc),
+ meeting_id=meeting.id,
+ track_keys=[],
+ )
+ )
+
+ transcript = await transcripts_controller.add(
+ name="Test Empty Track Keys Transcript",
+ source_kind=SourceKind.ROOM,
+ recording_id=recording.id,
+ meeting_id=meeting.id,
+ )
+
+ # Add consent denial
+ await meeting_consent_controller.upsert(
+ MeetingConsent(
+ meeting_id=meeting.id,
+ user_id="test-user-3",
+ consent_given=False,
+ consent_timestamp=datetime.now(timezone.utc),
+ )
+ )
+
+ # Mock get_transcripts_storage (master credentials with bucket override)
+ with patch(
+ "reflector.pipelines.main_live_pipeline.get_transcripts_storage"
+ ) as mock_get_transcripts_storage:
+ mock_master_storage = MagicMock()
+ mock_master_storage.delete_file = AsyncMock()
+ mock_get_transcripts_storage.return_value = mock_master_storage
+
+ # Run cleanup
+ await cleanup_consent(transcript_id=transcript.id)
+
+ # Verify master storage was used with bucket override
+ assert mock_master_storage.delete_file.call_count == 1
+ call_args = mock_master_storage.delete_file.call_args
+ assert call_args[0][0] == recording.object_key
+ assert call_args[1].get("bucket") == "test-bucket" # Verify bucket override!
+
+
+@pytest.mark.asyncio
+async def test_consent_cleanup_partial_failure_doesnt_mark_deleted():
+ room = await rooms_controller.add(
+ name="Test Room 4",
+ user_id="test-user",
+ zulip_auto_post=False,
+ zulip_stream="",
+ zulip_topic="",
+ is_locked=False,
+ room_mode="normal",
+ recording_type="cloud",
+ recording_trigger="automatic",
+ is_shared=False,
+ platform="daily",
+ )
+
+ # Create meeting
+ meeting = await meetings_controller.create(
+ id="test-multitrack-meeting-4",
+ room_name="test-room-20250101120003",
+ room_url="https://test.daily.co/test-room-4",
+ host_room_url="https://test.daily.co/test-room-4",
+ start_date=datetime.now(timezone.utc),
+ end_date=datetime.now(timezone.utc),
+ room=room,
+ )
+
+ track_keys = [
+ "recordings/test-room-20250101120003/track-0.webm",
+ "recordings/test-room-20250101120003/track-1.webm",
+ "recordings/test-room-20250101120003/track-2.webm",
+ ]
+ recording = await recordings_controller.create(
+ Recording(
+ bucket_name="test-bucket",
+ object_key="recordings/test-room-20250101120003",
+ recorded_at=datetime.now(timezone.utc),
+ meeting_id=meeting.id,
+ track_keys=track_keys,
+ )
+ )
+
+ # Create transcript
+ transcript = await transcripts_controller.add(
+ name="Test Partial Failure Transcript",
+ source_kind=SourceKind.ROOM,
+ recording_id=recording.id,
+ meeting_id=meeting.id,
+ )
+
+ # Add consent denial
+ await meeting_consent_controller.upsert(
+ MeetingConsent(
+ meeting_id=meeting.id,
+ user_id="test-user-4",
+ consent_given=False,
+ consent_timestamp=datetime.now(timezone.utc),
+ )
+ )
+
+ # Mock get_transcripts_storage (master credentials with bucket override) with partial failure
+ with patch(
+ "reflector.pipelines.main_live_pipeline.get_transcripts_storage"
+ ) as mock_get_transcripts_storage:
+ mock_master_storage = MagicMock()
+
+ call_count = 0
+
+ async def delete_side_effect(key, bucket=None):
+ nonlocal call_count
+ call_count += 1
+ if call_count == 2:
+ raise Exception("S3 deletion failed")
+
+ mock_master_storage.delete_file = AsyncMock(side_effect=delete_side_effect)
+ mock_get_transcripts_storage.return_value = mock_master_storage
+
+ await cleanup_consent(transcript_id=transcript.id)
+
+ # Verify master storage was called with bucket override
+ assert mock_master_storage.delete_file.call_count == 3
+
+ updated_transcript = await transcripts_controller.get_by_id(transcript.id)
+ assert (
+ updated_transcript.audio_deleted is None
+ or updated_transcript.audio_deleted is False
+ )
diff --git a/server/tests/test_pipeline_main_file.py b/server/tests/test_pipeline_main_file.py
index f86dc85d..825c8389 100644
--- a/server/tests/test_pipeline_main_file.py
+++ b/server/tests/test_pipeline_main_file.py
@@ -127,18 +127,27 @@ async def mock_storage():
from reflector.storage.base import Storage
class TestStorage(Storage):
- async def _put_file(self, path, data):
+ async def _put_file(self, path, data, bucket=None):
return None
- async def _get_file_url(self, path):
+ async def _get_file_url(
+ self,
+ path,
+ operation: str = "get_object",
+ expires_in: int = 3600,
+ bucket=None,
+ ):
return f"http://test-storage/{path}"
- async def _get_file(self, path):
+ async def _get_file(self, path, bucket=None):
return b"test_audio_data"
- async def _delete_file(self, path):
+ async def _delete_file(self, path, bucket=None):
return None
+ async def _stream_to_fileobj(self, path, fileobj, bucket=None):
+ fileobj.write(b"test_audio_data")
+
storage = TestStorage()
# Add mock tracking for verification
storage._put_file = AsyncMock(side_effect=storage._put_file)
@@ -181,7 +190,7 @@ async def mock_waveform_processor():
async def mock_topic_detector():
"""Mock TranscriptTopicDetectorProcessor"""
with patch(
- "reflector.pipelines.main_file_pipeline.TranscriptTopicDetectorProcessor"
+ "reflector.pipelines.topic_processing.TranscriptTopicDetectorProcessor"
) as mock_topic_class:
mock_topic = AsyncMock()
mock_topic.set_pipeline = MagicMock()
@@ -218,7 +227,7 @@ async def mock_topic_detector():
async def mock_title_processor():
"""Mock TranscriptFinalTitleProcessor"""
with patch(
- "reflector.pipelines.main_file_pipeline.TranscriptFinalTitleProcessor"
+ "reflector.pipelines.topic_processing.TranscriptFinalTitleProcessor"
) as mock_title_class:
mock_title = AsyncMock()
mock_title.set_pipeline = MagicMock()
@@ -247,7 +256,7 @@ async def mock_title_processor():
async def mock_summary_processor():
"""Mock TranscriptFinalSummaryProcessor"""
with patch(
- "reflector.pipelines.main_file_pipeline.TranscriptFinalSummaryProcessor"
+ "reflector.pipelines.topic_processing.TranscriptFinalSummaryProcessor"
) as mock_summary_class:
mock_summary = AsyncMock()
mock_summary.set_pipeline = MagicMock()
diff --git a/server/tests/test_room_ics_api.py b/server/tests/test_room_ics_api.py
index 8e7cf76f..79512995 100644
--- a/server/tests/test_room_ics_api.py
+++ b/server/tests/test_room_ics_api.py
@@ -48,6 +48,7 @@ async def test_create_room_with_ics_fields(authenticated_client):
"ics_url": "https://calendar.example.com/test.ics",
"ics_fetch_interval": 600,
"ics_enabled": True,
+ "platform": "daily",
},
)
assert response.status_code == 200
@@ -75,6 +76,7 @@ async def test_update_room_ics_configuration(authenticated_client):
"is_shared": False,
"webhook_url": "",
"webhook_secret": "",
+ "platform": "daily",
},
)
assert response.status_code == 200
@@ -111,6 +113,7 @@ async def test_trigger_ics_sync(authenticated_client):
is_shared=False,
ics_url="https://calendar.example.com/api.ics",
ics_enabled=True,
+ platform="daily",
)
cal = Calendar()
@@ -154,6 +157,7 @@ async def test_trigger_ics_sync_unauthorized(client):
is_shared=False,
ics_url="https://calendar.example.com/api.ics",
ics_enabled=True,
+ platform="daily",
)
response = await client.post(f"/rooms/{room.name}/ics/sync")
@@ -176,6 +180,7 @@ async def test_trigger_ics_sync_not_configured(authenticated_client):
recording_trigger="automatic-2nd-participant",
is_shared=False,
ics_enabled=False,
+ platform="daily",
)
response = await client.post(f"/rooms/{room.name}/ics/sync")
@@ -200,6 +205,7 @@ async def test_get_ics_status(authenticated_client):
ics_url="https://calendar.example.com/status.ics",
ics_enabled=True,
ics_fetch_interval=300,
+ platform="daily",
)
now = datetime.now(timezone.utc)
@@ -231,6 +237,7 @@ async def test_get_ics_status_unauthorized(client):
is_shared=False,
ics_url="https://calendar.example.com/status.ics",
ics_enabled=True,
+ platform="daily",
)
response = await client.get(f"/rooms/{room.name}/ics/status")
@@ -252,6 +259,7 @@ async def test_list_room_meetings(authenticated_client):
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
+ platform="daily",
)
now = datetime.now(timezone.utc)
@@ -298,6 +306,7 @@ async def test_list_room_meetings_non_owner(client):
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
+ platform="daily",
)
event = CalendarEvent(
@@ -334,6 +343,7 @@ async def test_list_upcoming_meetings(authenticated_client):
recording_type="cloud",
recording_trigger="automatic-2nd-participant",
is_shared=False,
+ platform="daily",
)
now = datetime.now(timezone.utc)
diff --git a/server/tests/test_storage.py b/server/tests/test_storage.py
new file mode 100644
index 00000000..ccfc3dbd
--- /dev/null
+++ b/server/tests/test_storage.py
@@ -0,0 +1,321 @@
+"""Tests for storage abstraction layer."""
+
+import io
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from botocore.exceptions import ClientError
+
+from reflector.storage.base import StoragePermissionError
+from reflector.storage.storage_aws import AwsStorage
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_stream_to_fileobj():
+ """Test that AWS storage can stream directly to a file object without loading into memory."""
+ # Setup
+ storage = AwsStorage(
+ aws_bucket_name="test-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ # Mock download_fileobj to write data
+ async def mock_download(Bucket, Key, Fileobj, **kwargs):
+ Fileobj.write(b"chunk1chunk2")
+
+ mock_client = AsyncMock()
+ mock_client.download_fileobj = AsyncMock(side_effect=mock_download)
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ # Patch the session client
+ with patch.object(storage.session, "client", return_value=mock_client):
+ # Create a file-like object to stream to
+ output = io.BytesIO()
+
+ # Act - stream to file object
+ await storage.stream_to_fileobj("test-file.mp4", output, bucket="test-bucket")
+
+ # Assert
+ mock_client.download_fileobj.assert_called_once_with(
+ Bucket="test-bucket", Key="test-file.mp4", Fileobj=output
+ )
+
+ # Check that data was written to output
+ output.seek(0)
+ assert output.read() == b"chunk1chunk2"
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_stream_to_fileobj_with_folder():
+ """Test streaming with folder prefix in bucket name."""
+ storage = AwsStorage(
+ aws_bucket_name="test-bucket/recordings",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ async def mock_download(Bucket, Key, Fileobj, **kwargs):
+ Fileobj.write(b"data")
+
+ mock_client = AsyncMock()
+ mock_client.download_fileobj = AsyncMock(side_effect=mock_download)
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch.object(storage.session, "client", return_value=mock_client):
+ output = io.BytesIO()
+ await storage.stream_to_fileobj("file.mp4", output, bucket="other-bucket")
+
+ # Should use folder prefix from instance config
+ mock_client.download_fileobj.assert_called_once_with(
+ Bucket="other-bucket", Key="recordings/file.mp4", Fileobj=output
+ )
+
+
+@pytest.mark.asyncio
+async def test_storage_base_class_stream_to_fileobj():
+ """Test that base Storage class has stream_to_fileobj method."""
+ from reflector.storage.base import Storage
+
+ # Verify method exists in base class
+ assert hasattr(Storage, "stream_to_fileobj")
+
+ # Create a mock storage instance
+ storage = MagicMock(spec=Storage)
+ storage.stream_to_fileobj = AsyncMock()
+
+ # Should be callable
+ await storage.stream_to_fileobj("file.mp4", io.BytesIO())
+ storage.stream_to_fileobj.assert_called_once()
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_stream_uses_download_fileobj():
+ """Test that download_fileobj is called correctly."""
+ storage = AwsStorage(
+ aws_bucket_name="test-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ async def mock_download(Bucket, Key, Fileobj, **kwargs):
+ Fileobj.write(b"data")
+
+ mock_client = AsyncMock()
+ mock_client.download_fileobj = AsyncMock(side_effect=mock_download)
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch.object(storage.session, "client", return_value=mock_client):
+ output = io.BytesIO()
+ await storage.stream_to_fileobj("test.mp4", output)
+
+ # Verify download_fileobj was called with correct parameters
+ mock_client.download_fileobj.assert_called_once_with(
+ Bucket="test-bucket", Key="test.mp4", Fileobj=output
+ )
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_handles_access_denied_error():
+ """Test that AccessDenied errors are caught and wrapped in StoragePermissionError."""
+ storage = AwsStorage(
+ aws_bucket_name="test-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ # Mock ClientError with AccessDenied
+ error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}}
+ mock_client = AsyncMock()
+ mock_client.put_object = AsyncMock(
+ side_effect=ClientError(error_response, "PutObject")
+ )
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch.object(storage.session, "client", return_value=mock_client):
+ with pytest.raises(StoragePermissionError) as exc_info:
+ await storage.put_file("test.txt", b"data")
+
+ # Verify error message contains expected information
+ error_msg = str(exc_info.value)
+ assert "AccessDenied" in error_msg
+ assert "default bucket 'test-bucket'" in error_msg
+ assert "S3 upload failed" in error_msg
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_handles_no_such_bucket_error():
+ """Test that NoSuchBucket errors are caught and wrapped in StoragePermissionError."""
+ storage = AwsStorage(
+ aws_bucket_name="test-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ # Mock ClientError with NoSuchBucket
+ error_response = {
+ "Error": {
+ "Code": "NoSuchBucket",
+ "Message": "The specified bucket does not exist",
+ }
+ }
+ mock_client = AsyncMock()
+ mock_client.delete_object = AsyncMock(
+ side_effect=ClientError(error_response, "DeleteObject")
+ )
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch.object(storage.session, "client", return_value=mock_client):
+ with pytest.raises(StoragePermissionError) as exc_info:
+ await storage.delete_file("test.txt")
+
+ # Verify error message contains expected information
+ error_msg = str(exc_info.value)
+ assert "NoSuchBucket" in error_msg
+ assert "default bucket 'test-bucket'" in error_msg
+ assert "S3 delete failed" in error_msg
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_error_message_with_bucket_override():
+ """Test that error messages correctly show overridden bucket."""
+ storage = AwsStorage(
+ aws_bucket_name="default-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ # Mock ClientError with AccessDenied
+ error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}}
+ mock_client = AsyncMock()
+ mock_client.get_object = AsyncMock(
+ side_effect=ClientError(error_response, "GetObject")
+ )
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch.object(storage.session, "client", return_value=mock_client):
+ with pytest.raises(StoragePermissionError) as exc_info:
+ await storage.get_file("test.txt", bucket="override-bucket")
+
+ # Verify error message shows overridden bucket, not default
+ error_msg = str(exc_info.value)
+ assert "overridden bucket 'override-bucket'" in error_msg
+ assert "default-bucket" not in error_msg
+ assert "S3 download failed" in error_msg
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_reraises_non_handled_errors():
+ """Test that non-AccessDenied/NoSuchBucket errors are re-raised as-is."""
+ storage = AwsStorage(
+ aws_bucket_name="test-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ # Mock ClientError with different error code
+ error_response = {
+ "Error": {"Code": "InternalError", "Message": "Internal Server Error"}
+ }
+ mock_client = AsyncMock()
+ mock_client.put_object = AsyncMock(
+ side_effect=ClientError(error_response, "PutObject")
+ )
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch.object(storage.session, "client", return_value=mock_client):
+ # Should raise ClientError, not StoragePermissionError
+ with pytest.raises(ClientError) as exc_info:
+ await storage.put_file("test.txt", b"data")
+
+ # Verify it's the original ClientError
+ assert exc_info.value.response["Error"]["Code"] == "InternalError"
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_presign_url_handles_errors():
+ """Test that presigned URL generation handles permission errors."""
+ storage = AwsStorage(
+ aws_bucket_name="test-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ # Mock ClientError with AccessDenied during presign operation
+ error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}}
+ mock_client = AsyncMock()
+ mock_client.generate_presigned_url = AsyncMock(
+ side_effect=ClientError(error_response, "GeneratePresignedUrl")
+ )
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch.object(storage.session, "client", return_value=mock_client):
+ with pytest.raises(StoragePermissionError) as exc_info:
+ await storage.get_file_url("test.txt")
+
+ # Verify error message
+ error_msg = str(exc_info.value)
+ assert "S3 presign failed" in error_msg
+ assert "AccessDenied" in error_msg
+
+
+@pytest.mark.asyncio
+async def test_aws_storage_list_objects_handles_errors():
+ """Test that list_objects handles permission errors."""
+ storage = AwsStorage(
+ aws_bucket_name="test-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ )
+
+ # Mock ClientError during list operation
+ error_response = {"Error": {"Code": "AccessDenied", "Message": "Access Denied"}}
+ mock_paginator = MagicMock()
+
+ async def mock_paginate(*args, **kwargs):
+ raise ClientError(error_response, "ListObjectsV2")
+ yield # Make it an async generator
+
+ mock_paginator.paginate = mock_paginate
+
+ mock_client = AsyncMock()
+ mock_client.get_paginator = MagicMock(return_value=mock_paginator)
+ mock_client.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_client.__aexit__ = AsyncMock(return_value=None)
+
+ with patch.object(storage.session, "client", return_value=mock_client):
+ with pytest.raises(StoragePermissionError) as exc_info:
+ await storage.list_objects(prefix="test/")
+
+ error_msg = str(exc_info.value)
+ assert "S3 list_objects failed" in error_msg
+ assert "AccessDenied" in error_msg
+
+
+def test_aws_storage_constructor_rejects_mixed_auth():
+ """Test that constructor rejects both role_arn and access keys."""
+ with pytest.raises(ValueError, match="cannot use both.*role_arn.*access keys"):
+ AwsStorage(
+ aws_bucket_name="test-bucket",
+ aws_region="us-east-1",
+ aws_access_key_id="test-key",
+ aws_secret_access_key="test-secret",
+ aws_role_arn="arn:aws:iam::123456789012:role/test-role",
+ )
diff --git a/server/tests/test_transcripts_recording_deletion.py b/server/tests/test_transcripts_recording_deletion.py
index 810fe567..3a632612 100644
--- a/server/tests/test_transcripts_recording_deletion.py
+++ b/server/tests/test_transcripts_recording_deletion.py
@@ -22,13 +22,16 @@ async def test_recording_deleted_with_transcript():
recording_id=recording.id,
)
- with patch("reflector.db.transcripts.get_recordings_storage") as mock_get_storage:
+ with patch("reflector.db.transcripts.get_transcripts_storage") as mock_get_storage:
storage_instance = mock_get_storage.return_value
storage_instance.delete_file = AsyncMock()
await transcripts_controller.remove_by_id(transcript.id)
- storage_instance.delete_file.assert_awaited_once_with(recording.object_key)
+ # Should be called with bucket override
+ storage_instance.delete_file.assert_awaited_once_with(
+ recording.object_key, bucket=recording.bucket_name
+ )
assert await recordings_controller.get_by_id(recording.id) is None
assert await transcripts_controller.get_by_id(transcript.id) is None
diff --git a/server/tests/test_utils_daily.py b/server/tests/test_utils_daily.py
new file mode 100644
index 00000000..356ffc94
--- /dev/null
+++ b/server/tests/test_utils_daily.py
@@ -0,0 +1,17 @@
+import pytest
+
+from reflector.utils.daily import extract_base_room_name
+
+
+@pytest.mark.parametrize(
+ "daily_room_name,expected",
+ [
+ ("daily-20251020193458", "daily"),
+ ("daily-2-20251020193458", "daily-2"),
+ ("my-room-name-20251020193458", "my-room-name"),
+ ("room-with-numbers-123-20251020193458", "room-with-numbers-123"),
+ ("x-20251020193458", "x"),
+ ],
+)
+def test_extract_base_room_name(daily_room_name, expected):
+ assert extract_base_room_name(daily_room_name) == expected
diff --git a/server/tests/test_utils_url.py b/server/tests/test_utils_url.py
new file mode 100644
index 00000000..c833983c
--- /dev/null
+++ b/server/tests/test_utils_url.py
@@ -0,0 +1,63 @@
+"""Tests for URL utility functions."""
+
+from reflector.utils.url import add_query_param
+
+
+class TestAddQueryParam:
+ """Test the add_query_param function."""
+
+ def test_add_param_to_url_without_query(self):
+ """Should add query param with ? to URL without existing params."""
+ url = "https://example.com/room"
+ result = add_query_param(url, "t", "token123")
+ assert result == "https://example.com/room?t=token123"
+
+ def test_add_param_to_url_with_existing_query(self):
+ """Should add query param with & to URL with existing params."""
+ url = "https://example.com/room?existing=param"
+ result = add_query_param(url, "t", "token123")
+ assert result == "https://example.com/room?existing=param&t=token123"
+
+ def test_add_param_to_url_with_multiple_existing_params(self):
+ """Should add query param to URL with multiple existing params."""
+ url = "https://example.com/room?param1=value1¶m2=value2"
+ result = add_query_param(url, "t", "token123")
+ assert (
+ result == "https://example.com/room?param1=value1¶m2=value2&t=token123"
+ )
+
+ def test_add_param_with_special_characters(self):
+ """Should properly encode special characters in param value."""
+ url = "https://example.com/room"
+ result = add_query_param(url, "name", "hello world")
+ assert result == "https://example.com/room?name=hello+world"
+
+ def test_add_param_to_url_with_fragment(self):
+ """Should preserve URL fragment when adding query param."""
+ url = "https://example.com/room#section"
+ result = add_query_param(url, "t", "token123")
+ assert result == "https://example.com/room?t=token123#section"
+
+ def test_add_param_to_url_with_query_and_fragment(self):
+ """Should preserve fragment when adding param to URL with existing query."""
+ url = "https://example.com/room?existing=param#section"
+ result = add_query_param(url, "t", "token123")
+ assert result == "https://example.com/room?existing=param&t=token123#section"
+
+ def test_add_param_overwrites_existing_param(self):
+ """Should overwrite existing param with same name."""
+ url = "https://example.com/room?t=oldtoken"
+ result = add_query_param(url, "t", "newtoken")
+ assert result == "https://example.com/room?t=newtoken"
+
+ def test_url_without_scheme(self):
+ """Should handle URLs without scheme (relative URLs)."""
+ url = "/room/path"
+ result = add_query_param(url, "t", "token123")
+ assert result == "/room/path?t=token123"
+
+ def test_empty_url(self):
+ """Should handle empty URL."""
+ url = ""
+ result = add_query_param(url, "t", "token123")
+ assert result == "?t=token123"
diff --git a/server/tests/test_video_platforms_factory.py b/server/tests/test_video_platforms_factory.py
new file mode 100644
index 00000000..6c8c02c5
--- /dev/null
+++ b/server/tests/test_video_platforms_factory.py
@@ -0,0 +1,58 @@
+"""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"
diff --git a/www/app/[roomName]/[meetingId]/page.tsx b/www/app/[roomName]/[meetingId]/page.tsx
index 8ce405ba..725aa571 100644
--- a/www/app/[roomName]/[meetingId]/page.tsx
+++ b/www/app/[roomName]/[meetingId]/page.tsx
@@ -1,3 +1,3 @@
-import Room from "../room";
+import RoomContainer from "../components/RoomContainer";
-export default Room;
+export default RoomContainer;
diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx
new file mode 100644
index 00000000..920f8624
--- /dev/null
+++ b/www/app/[roomName]/components/DailyRoom.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { Box } from "@chakra-ui/react";
+import { useRouter } from "next/navigation";
+import DailyIframe, { DailyCall } from "@daily-co/daily-js";
+import type { components } from "../../reflector-api";
+import { useAuth } from "../../lib/AuthProvider";
+import {
+ ConsentDialogButton,
+ recordingTypeRequiresConsent,
+} from "../../lib/consent";
+
+type Meeting = components["schemas"]["Meeting"];
+
+interface DailyRoomProps {
+ meeting: Meeting;
+}
+
+export default function DailyRoom({ meeting }: DailyRoomProps) {
+ const router = useRouter();
+ const auth = useAuth();
+ const status = auth.status;
+ const containerRef = useRef(null);
+
+ const roomUrl = meeting?.host_room_url || meeting?.room_url;
+
+ const isLoading = status === "loading";
+
+ const handleLeave = useCallback(() => {
+ router.push("/browse");
+ }, [router]);
+
+ useEffect(() => {
+ if (isLoading || !roomUrl || !containerRef.current) return;
+
+ let frame: DailyCall | null = null;
+ let destroyed = false;
+
+ const createAndJoin = async () => {
+ try {
+ const existingFrame = DailyIframe.getCallInstance();
+ if (existingFrame) {
+ await existingFrame.destroy();
+ }
+
+ frame = DailyIframe.createFrame(containerRef.current!, {
+ iframeStyle: {
+ width: "100vw",
+ height: "100vh",
+ border: "none",
+ },
+ showLeaveButton: true,
+ showFullscreenButton: true,
+ });
+
+ if (destroyed) {
+ await frame.destroy();
+ return;
+ }
+
+ frame.on("left-meeting", handleLeave);
+ await frame.join({ url: roomUrl });
+ } catch (error) {
+ console.error("Error creating Daily frame:", error);
+ }
+ };
+
+ createAndJoin();
+
+ return () => {
+ destroyed = true;
+ if (frame) {
+ frame.destroy().catch((e) => {
+ console.error("Error destroying frame:", e);
+ });
+ }
+ };
+ }, [roomUrl, isLoading, handleLeave]);
+
+ if (!roomUrl) {
+ return null;
+ }
+
+ return (
+
+
+ {meeting.recording_type &&
+ recordingTypeRequiresConsent(meeting.recording_type) &&
+ meeting.id && }
+
+ );
+}
diff --git a/www/app/[roomName]/components/RoomContainer.tsx b/www/app/[roomName]/components/RoomContainer.tsx
new file mode 100644
index 00000000..bfcd82f7
--- /dev/null
+++ b/www/app/[roomName]/components/RoomContainer.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { roomMeetingUrl } from "../../lib/routes";
+import { useCallback, useEffect, useState, use } from "react";
+import { Box, Text, Spinner } from "@chakra-ui/react";
+import { useRouter } from "next/navigation";
+import {
+ useRoomGetByName,
+ useRoomsCreateMeeting,
+ useRoomGetMeeting,
+} from "../../lib/apiHooks";
+import type { components } from "../../reflector-api";
+import MeetingSelection from "../MeetingSelection";
+import useRoomDefaultMeeting from "../useRoomDefaultMeeting";
+import WherebyRoom from "./WherebyRoom";
+import DailyRoom from "./DailyRoom";
+import { useAuth } from "../../lib/AuthProvider";
+import { useError } from "../../(errors)/errorContext";
+import { parseNonEmptyString } from "../../lib/utils";
+import { printApiError } from "../../api/_error";
+
+type Meeting = components["schemas"]["Meeting"];
+
+export type RoomDetails = {
+ params: Promise<{
+ roomName: string;
+ meetingId?: string;
+ }>;
+};
+
+function LoadingSpinner() {
+ return (
+
+
+
+ );
+}
+
+export default function RoomContainer(details: RoomDetails) {
+ const params = use(details.params);
+ const roomName = parseNonEmptyString(
+ params.roomName,
+ true,
+ "panic! params.roomName is required",
+ );
+ const router = useRouter();
+ const auth = useAuth();
+ const status = auth.status;
+ const isAuthenticated = status === "authenticated";
+ const { setError } = useError();
+
+ const roomQuery = useRoomGetByName(roomName);
+ const createMeetingMutation = useRoomsCreateMeeting();
+
+ const room = roomQuery.data;
+
+ const pageMeetingId = params.meetingId;
+
+ const defaultMeeting = useRoomDefaultMeeting(
+ room && !room.ics_enabled && !pageMeetingId ? roomName : null,
+ );
+
+ const explicitMeeting = useRoomGetMeeting(roomName, pageMeetingId || null);
+
+ const meeting = explicitMeeting.data || defaultMeeting.response;
+
+ const isLoading =
+ status === "loading" ||
+ roomQuery.isLoading ||
+ defaultMeeting?.loading ||
+ explicitMeeting.isLoading ||
+ createMeetingMutation.isPending;
+
+ const errors = [
+ explicitMeeting.error,
+ defaultMeeting.error,
+ roomQuery.error,
+ createMeetingMutation.error,
+ ].filter(Boolean);
+
+ const isOwner =
+ isAuthenticated && room ? auth.user?.id === room.user_id : false;
+
+ const handleMeetingSelect = (selectedMeeting: Meeting) => {
+ router.push(
+ roomMeetingUrl(
+ roomName,
+ parseNonEmptyString(
+ selectedMeeting.id,
+ true,
+ "panic! selectedMeeting.id is required",
+ ),
+ ),
+ );
+ };
+
+ const handleCreateUnscheduled = async () => {
+ try {
+ const newMeeting = await createMeetingMutation.mutateAsync({
+ params: {
+ path: { room_name: roomName },
+ },
+ body: {
+ allow_duplicated: room ? room.ics_enabled : false,
+ },
+ });
+ handleMeetingSelect(newMeeting);
+ } catch (err) {
+ console.error("Failed to create meeting:", err);
+ }
+ };
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (!room) {
+ return (
+
+ Room not found
+
+ );
+ }
+
+ if (room.ics_enabled && !params.meetingId) {
+ return (
+
+ );
+ }
+
+ if (errors.length > 0) {
+ return (
+
+ {errors.map((error, i) => (
+
+ {printApiError(error)}
+
+ ))}
+
+ );
+ }
+
+ if (!meeting) {
+ return ;
+ }
+
+ const platform = meeting.platform;
+
+ if (!platform) {
+ return (
+
+ Meeting platform not configured
+
+ );
+ }
+
+ switch (platform) {
+ case "daily":
+ return ;
+ case "whereby":
+ return ;
+ default: {
+ const _exhaustive: never = platform;
+ return (
+
+ Unknown platform: {platform}
+
+ );
+ }
+ }
+}
diff --git a/www/app/[roomName]/components/WherebyRoom.tsx b/www/app/[roomName]/components/WherebyRoom.tsx
new file mode 100644
index 00000000..d670b4e2
--- /dev/null
+++ b/www/app/[roomName]/components/WherebyRoom.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useCallback, useEffect, useRef, RefObject } from "react";
+import { useRouter } from "next/navigation";
+import type { components } from "../../reflector-api";
+import { useAuth } from "../../lib/AuthProvider";
+import { getWherebyUrl, useWhereby } from "../../lib/wherebyClient";
+import { assertExistsAndNonEmptyString, NonEmptyString } from "../../lib/utils";
+import {
+ ConsentDialogButton as BaseConsentDialogButton,
+ useConsentDialog,
+ recordingTypeRequiresConsent,
+} from "../../lib/consent";
+
+type Meeting = components["schemas"]["Meeting"];
+
+interface WherebyRoomProps {
+ meeting: Meeting;
+}
+
+function WherebyConsentDialogButton({
+ meetingId,
+ wherebyRef,
+}: {
+ meetingId: NonEmptyString;
+ wherebyRef: React.RefObject;
+}) {
+ const previousFocusRef = useRef(null);
+
+ useEffect(() => {
+ const element = wherebyRef.current;
+ if (!element) return;
+
+ const handleWherebyReady = () => {
+ previousFocusRef.current = document.activeElement as HTMLElement;
+ };
+
+ element.addEventListener("ready", handleWherebyReady);
+
+ return () => {
+ element.removeEventListener("ready", handleWherebyReady);
+ if (previousFocusRef.current && document.activeElement === element) {
+ previousFocusRef.current.focus();
+ }
+ };
+ }, [wherebyRef]);
+
+ return ;
+}
+
+export default function WherebyRoom({ meeting }: WherebyRoomProps) {
+ const wherebyLoaded = useWhereby();
+ const wherebyRef = useRef(null);
+ const router = useRouter();
+ const auth = useAuth();
+ const status = auth.status;
+ const isAuthenticated = status === "authenticated";
+
+ const wherebyRoomUrl = getWherebyUrl(meeting);
+ const recordingType = meeting.recording_type;
+ const meetingId = meeting.id;
+
+ const isLoading = status === "loading";
+
+ const handleLeave = useCallback(() => {
+ router.push("/browse");
+ }, [router]);
+
+ useEffect(() => {
+ if (isLoading || !isAuthenticated || !wherebyRoomUrl || !wherebyLoaded)
+ return;
+
+ wherebyRef.current?.addEventListener("leave", handleLeave);
+
+ return () => {
+ wherebyRef.current?.removeEventListener("leave", handleLeave);
+ };
+ }, [handleLeave, wherebyRoomUrl, isLoading, isAuthenticated, wherebyLoaded]);
+
+ if (!wherebyRoomUrl || !wherebyLoaded) {
+ return null;
+ }
+
+ return (
+ <>
+
+ {recordingType &&
+ recordingTypeRequiresConsent(recordingType) &&
+ meetingId && (
+
+ )}
+ >
+ );
+}
diff --git a/www/app/[roomName]/page.tsx b/www/app/[roomName]/page.tsx
index 1aaca4c7..87651a50 100644
--- a/www/app/[roomName]/page.tsx
+++ b/www/app/[roomName]/page.tsx
@@ -1,3 +1,3 @@
-import Room from "./room";
+import RoomContainer from "./components/RoomContainer";
-export default Room;
+export default RoomContainer;
diff --git a/www/app/lib/consent/ConsentDialog.tsx b/www/app/lib/consent/ConsentDialog.tsx
new file mode 100644
index 00000000..488599d0
--- /dev/null
+++ b/www/app/lib/consent/ConsentDialog.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { Box, Button, Text, VStack, HStack } from "@chakra-ui/react";
+import { CONSENT_DIALOG_TEXT } from "./constants";
+
+interface ConsentDialogProps {
+ onAccept: () => void;
+ onReject: () => void;
+}
+
+export function ConsentDialog({ onAccept, onReject }: ConsentDialogProps) {
+ return (
+
+
+
+ {CONSENT_DIALOG_TEXT.question}
+
+
+
+ {CONSENT_DIALOG_TEXT.rejectButton}
+
+
+ {CONSENT_DIALOG_TEXT.acceptButton}
+
+
+
+
+ );
+}
diff --git a/www/app/lib/consent/ConsentDialogButton.tsx b/www/app/lib/consent/ConsentDialogButton.tsx
new file mode 100644
index 00000000..2c1d084b
--- /dev/null
+++ b/www/app/lib/consent/ConsentDialogButton.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { Button, Icon } from "@chakra-ui/react";
+import { FaBars } from "react-icons/fa6";
+import { useConsentDialog } from "./useConsentDialog";
+import {
+ CONSENT_BUTTON_TOP_OFFSET,
+ CONSENT_BUTTON_LEFT_OFFSET,
+ CONSENT_BUTTON_Z_INDEX,
+ CONSENT_DIALOG_TEXT,
+} from "./constants";
+
+interface ConsentDialogButtonProps {
+ meetingId: string;
+}
+
+export function ConsentDialogButton({ meetingId }: ConsentDialogButtonProps) {
+ const { showConsentModal, consentState, hasConsent, consentLoading } =
+ useConsentDialog(meetingId);
+
+ if (!consentState.ready || hasConsent(meetingId) || consentLoading) {
+ return null;
+ }
+
+ return (
+
+ {CONSENT_DIALOG_TEXT.triggerButton}
+
+
+ );
+}
diff --git a/www/app/lib/consent/constants.ts b/www/app/lib/consent/constants.ts
new file mode 100644
index 00000000..41e7c7e1
--- /dev/null
+++ b/www/app/lib/consent/constants.ts
@@ -0,0 +1,12 @@
+export const CONSENT_BUTTON_TOP_OFFSET = "56px";
+export const CONSENT_BUTTON_LEFT_OFFSET = "8px";
+export const CONSENT_BUTTON_Z_INDEX = 1000;
+export const TOAST_CHECK_INTERVAL_MS = 100;
+
+export const CONSENT_DIALOG_TEXT = {
+ question:
+ "Can we have your permission to store this meeting's audio recording on our servers?",
+ acceptButton: "Yes, store the audio",
+ rejectButton: "No, delete after transcription",
+ triggerButton: "Meeting is being recorded",
+} as const;
diff --git a/www/app/lib/consent/index.ts b/www/app/lib/consent/index.ts
new file mode 100644
index 00000000..eabca8ac
--- /dev/null
+++ b/www/app/lib/consent/index.ts
@@ -0,0 +1,8 @@
+"use client";
+
+export { ConsentDialogButton } from "./ConsentDialogButton";
+export { ConsentDialog } from "./ConsentDialog";
+export { useConsentDialog } from "./useConsentDialog";
+export { recordingTypeRequiresConsent } from "./utils";
+export * from "./constants";
+export * from "./types";
diff --git a/www/app/lib/consent/types.ts b/www/app/lib/consent/types.ts
new file mode 100644
index 00000000..0bd15202
--- /dev/null
+++ b/www/app/lib/consent/types.ts
@@ -0,0 +1,9 @@
+export interface ConsentDialogResult {
+ showConsentModal: () => void;
+ consentState: {
+ ready: boolean;
+ consentAnsweredForMeetings?: Set;
+ };
+ hasConsent: (meetingId: string) => boolean;
+ consentLoading: boolean;
+}
diff --git a/www/app/lib/consent/useConsentDialog.tsx b/www/app/lib/consent/useConsentDialog.tsx
new file mode 100644
index 00000000..2a5c0ab3
--- /dev/null
+++ b/www/app/lib/consent/useConsentDialog.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { useCallback, useState, useEffect, useRef } from "react";
+import { toaster } from "../../components/ui/toaster";
+import { useRecordingConsent } from "../../recordingConsentContext";
+import { useMeetingAudioConsent } from "../apiHooks";
+import { ConsentDialog } from "./ConsentDialog";
+import { TOAST_CHECK_INTERVAL_MS } from "./constants";
+import type { ConsentDialogResult } from "./types";
+
+export function useConsentDialog(meetingId: string): ConsentDialogResult {
+ const { state: consentState, touch, hasConsent } = useRecordingConsent();
+ const [modalOpen, setModalOpen] = useState(false);
+ const audioConsentMutation = useMeetingAudioConsent();
+ const intervalRef = useRef(null);
+ const keydownHandlerRef = useRef<((event: KeyboardEvent) => void) | null>(
+ null,
+ );
+
+ useEffect(() => {
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ if (keydownHandlerRef.current) {
+ document.removeEventListener("keydown", keydownHandlerRef.current);
+ keydownHandlerRef.current = null;
+ }
+ };
+ }, []);
+
+ const handleConsent = useCallback(
+ async (given: boolean) => {
+ try {
+ await audioConsentMutation.mutateAsync({
+ params: {
+ path: { meeting_id: meetingId },
+ },
+ body: {
+ consent_given: given,
+ },
+ });
+
+ touch(meetingId);
+ } catch (error) {
+ console.error("Error submitting consent:", error);
+ }
+ },
+ [audioConsentMutation, touch, meetingId],
+ );
+
+ const showConsentModal = useCallback(() => {
+ if (modalOpen) return;
+
+ setModalOpen(true);
+
+ const toastId = toaster.create({
+ placement: "top",
+ duration: null,
+ render: ({ dismiss }) => (
+ {
+ handleConsent(true);
+ dismiss();
+ }}
+ onReject={() => {
+ handleConsent(false);
+ dismiss();
+ }}
+ />
+ ),
+ });
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ toastId.then((id) => toaster.dismiss(id));
+ }
+ };
+
+ keydownHandlerRef.current = handleKeyDown;
+ document.addEventListener("keydown", handleKeyDown);
+
+ toastId.then((id) => {
+ intervalRef.current = setInterval(() => {
+ if (!toaster.isActive(id)) {
+ setModalOpen(false);
+
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+
+ if (keydownHandlerRef.current) {
+ document.removeEventListener("keydown", keydownHandlerRef.current);
+ keydownHandlerRef.current = null;
+ }
+ }
+ }, TOAST_CHECK_INTERVAL_MS);
+ });
+ }, [handleConsent, modalOpen]);
+
+ return {
+ showConsentModal,
+ consentState,
+ hasConsent,
+ consentLoading: audioConsentMutation.isPending,
+ };
+}
diff --git a/www/app/lib/consent/utils.ts b/www/app/lib/consent/utils.ts
new file mode 100644
index 00000000..146bdd68
--- /dev/null
+++ b/www/app/lib/consent/utils.ts
@@ -0,0 +1,13 @@
+import type { components } from "../../reflector-api";
+
+type Meeting = components["schemas"]["Meeting"];
+
+/**
+ * Determines if a meeting's recording type requires user consent.
+ * Currently only "cloud" recordings require consent.
+ */
+export function recordingTypeRequiresConsent(
+ recordingType: Meeting["recording_type"],
+): boolean {
+ return recordingType === "cloud";
+}
diff --git a/www/app/lib/useLoginRequiredPages.ts b/www/app/lib/useLoginRequiredPages.ts
index 37ee96b1..d0dee1b6 100644
--- a/www/app/lib/useLoginRequiredPages.ts
+++ b/www/app/lib/useLoginRequiredPages.ts
@@ -3,6 +3,7 @@ import { PROTECTED_PAGES } from "./auth";
import { usePathname } from "next/navigation";
import { useAuth } from "./AuthProvider";
import { useEffect } from "react";
+import { featureEnabled } from "./features";
const HOME = "/" as const;
@@ -13,7 +14,9 @@ export const useLoginRequiredPages = () => {
const isNotLoggedIn = auth.status === "unauthenticated";
// safety
const isLastDestination = pathname === HOME;
- const shouldRedirect = isNotLoggedIn && isProtected && !isLastDestination;
+ const requireLogin = featureEnabled("requireLogin");
+ const shouldRedirect =
+ requireLogin && isNotLoggedIn && isProtected && !isLastDestination;
useEffect(() => {
if (!shouldRedirect) return;
// on the backend, the redirect goes straight to the auth provider, but we don't have it because it's hidden inside next-auth middleware
diff --git a/www/app/reflector-api.d.ts b/www/app/reflector-api.d.ts
index 1dc92f2b..9b9582ba 100644
--- a/www/app/reflector-api.d.ts
+++ b/www/app/reflector-api.d.ts
@@ -696,6 +696,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/v1/webhook": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Webhook
+ * @description Handle Daily webhook events.
+ */
+ post: operations["v1_webhook"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record;
export interface components {
@@ -852,6 +872,8 @@ export interface components {
* @default false
*/
ics_enabled: boolean;
+ /** Platform */
+ platform?: ("whereby" | "daily") | null;
};
/** CreateRoomMeeting */
CreateRoomMeeting: {
@@ -877,6 +899,22 @@ 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 */
@@ -1193,6 +1231,12 @@ export interface components {
calendar_metadata?: {
[key: string]: unknown;
} | null;
+ /**
+ * Platform
+ * @default whereby
+ * @enum {string}
+ */
+ platform: "whereby" | "daily";
};
/** MeetingConsentRequest */
MeetingConsentRequest: {
@@ -1279,6 +1323,12 @@ export interface components {
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
+ /**
+ * Platform
+ * @default whereby
+ * @enum {string}
+ */
+ platform: "whereby" | "daily";
};
/** RoomDetails */
RoomDetails: {
@@ -1325,6 +1375,12 @@ export interface components {
ics_last_sync?: string | null;
/** Ics Last Etag */
ics_last_etag?: string | null;
+ /**
+ * Platform
+ * @default whereby
+ * @enum {string}
+ */
+ platform: "whereby" | "daily";
/** Webhook Url */
webhook_url: string | null;
/** Webhook Secret */
@@ -1505,6 +1561,8 @@ export interface components {
ics_fetch_interval?: number | null;
/** Ics Enabled */
ics_enabled?: boolean | null;
+ /** Platform */
+ platform?: ("whereby" | "daily") | null;
};
/** UpdateTranscript */
UpdateTranscript: {
@@ -3191,4 +3249,37 @@ export interface operations {
};
};
};
+ v1_webhook: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["DailyWebhookEvent"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": unknown;
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
}
diff --git a/www/package.json b/www/package.json
index 5169dbe2..f4412db0 100644
--- a/www/package.json
+++ b/www/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"@chakra-ui/react": "^3.24.2",
+ "@daily-co/daily-js": "^0.84.0",
"@emotion/react": "^11.14.0",
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
diff --git a/www/pnpm-lock.yaml b/www/pnpm-lock.yaml
index 6c0a3d83..92667b7e 100644
--- a/www/pnpm-lock.yaml
+++ b/www/pnpm-lock.yaml
@@ -10,6 +10,9 @@ importers:
"@chakra-ui/react":
specifier: ^3.24.2
version: 3.24.2(@emotion/react@11.14.0(@types/react@18.2.20)(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ "@daily-co/daily-js":
+ specifier: ^0.84.0
+ version: 0.84.0
"@emotion/react":
specifier: ^11.14.0
version: 11.14.0(@types/react@18.2.20)(react@18.3.1)
@@ -487,6 +490,13 @@ packages:
}
engines: { node: ">=12" }
+ "@daily-co/daily-js@0.84.0":
+ resolution:
+ {
+ integrity: sha512-/ynXrMDDkRXhLlHxiFNf9QU5yw4ZGPr56wNARgja/Tiid71UIniundTavCNF5cMb2I1vNoMh7oEJ/q8stg/V7g==,
+ }
+ engines: { node: ">=10.0.0" }
+
"@emnapi/core@1.4.5":
resolution:
{
@@ -2293,6 +2303,13 @@ packages:
}
engines: { node: ">=18" }
+ "@sentry-internal/browser-utils@8.55.0":
+ resolution:
+ {
+ integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==,
+ }
+ engines: { node: ">=14.18" }
+
"@sentry-internal/feedback@10.11.0":
resolution:
{
@@ -2300,6 +2317,13 @@ packages:
}
engines: { node: ">=18" }
+ "@sentry-internal/feedback@8.55.0":
+ resolution:
+ {
+ integrity: sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==,
+ }
+ engines: { node: ">=14.18" }
+
"@sentry-internal/replay-canvas@10.11.0":
resolution:
{
@@ -2307,6 +2331,13 @@ packages:
}
engines: { node: ">=18" }
+ "@sentry-internal/replay-canvas@8.55.0":
+ resolution:
+ {
+ integrity: sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==,
+ }
+ engines: { node: ">=14.18" }
+
"@sentry-internal/replay@10.11.0":
resolution:
{
@@ -2314,6 +2345,13 @@ packages:
}
engines: { node: ">=18" }
+ "@sentry-internal/replay@8.55.0":
+ resolution:
+ {
+ integrity: sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==,
+ }
+ engines: { node: ">=14.18" }
+
"@sentry/babel-plugin-component-annotate@4.3.0":
resolution:
{
@@ -2328,6 +2366,13 @@ packages:
}
engines: { node: ">=18" }
+ "@sentry/browser@8.55.0":
+ resolution:
+ {
+ integrity: sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==,
+ }
+ engines: { node: ">=14.18" }
+
"@sentry/bundler-plugin-core@4.3.0":
resolution:
{
@@ -2421,6 +2466,13 @@ packages:
}
engines: { node: ">=18" }
+ "@sentry/core@8.55.0":
+ resolution:
+ {
+ integrity: sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==,
+ }
+ engines: { node: ">=14.18" }
+
"@sentry/nextjs@10.11.0":
resolution:
{
@@ -4029,6 +4081,12 @@ packages:
}
engines: { node: ">=8" }
+ bowser@2.12.1:
+ resolution:
+ {
+ integrity: sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==,
+ }
+
brace-expansion@1.1.12:
resolution:
{
@@ -9288,6 +9346,14 @@ snapshots:
"@jridgewell/trace-mapping": 0.3.9
optional: true
+ "@daily-co/daily-js@0.84.0":
+ dependencies:
+ "@babel/runtime": 7.28.2
+ "@sentry/browser": 8.55.0
+ bowser: 2.12.1
+ dequal: 2.0.3
+ events: 3.3.0
+
"@emnapi/core@1.4.5":
dependencies:
"@emnapi/wasi-threads": 1.0.4
@@ -10506,20 +10572,38 @@ snapshots:
dependencies:
"@sentry/core": 10.11.0
+ "@sentry-internal/browser-utils@8.55.0":
+ dependencies:
+ "@sentry/core": 8.55.0
+
"@sentry-internal/feedback@10.11.0":
dependencies:
"@sentry/core": 10.11.0
+ "@sentry-internal/feedback@8.55.0":
+ dependencies:
+ "@sentry/core": 8.55.0
+
"@sentry-internal/replay-canvas@10.11.0":
dependencies:
"@sentry-internal/replay": 10.11.0
"@sentry/core": 10.11.0
+ "@sentry-internal/replay-canvas@8.55.0":
+ dependencies:
+ "@sentry-internal/replay": 8.55.0
+ "@sentry/core": 8.55.0
+
"@sentry-internal/replay@10.11.0":
dependencies:
"@sentry-internal/browser-utils": 10.11.0
"@sentry/core": 10.11.0
+ "@sentry-internal/replay@8.55.0":
+ dependencies:
+ "@sentry-internal/browser-utils": 8.55.0
+ "@sentry/core": 8.55.0
+
"@sentry/babel-plugin-component-annotate@4.3.0": {}
"@sentry/browser@10.11.0":
@@ -10530,6 +10614,14 @@ snapshots:
"@sentry-internal/replay-canvas": 10.11.0
"@sentry/core": 10.11.0
+ "@sentry/browser@8.55.0":
+ dependencies:
+ "@sentry-internal/browser-utils": 8.55.0
+ "@sentry-internal/feedback": 8.55.0
+ "@sentry-internal/replay": 8.55.0
+ "@sentry-internal/replay-canvas": 8.55.0
+ "@sentry/core": 8.55.0
+
"@sentry/bundler-plugin-core@4.3.0":
dependencies:
"@babel/core": 7.28.3
@@ -10590,6 +10682,8 @@ snapshots:
"@sentry/core@10.11.0": {}
+ "@sentry/core@8.55.0": {}
+
"@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.3(@babel/core@7.28.3)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.90.0))(react@18.3.1)(webpack@5.101.3)":
dependencies:
"@opentelemetry/api": 1.9.0
@@ -11967,6 +12061,8 @@ snapshots:
binary-extensions@2.3.0: {}
+ bowser@2.12.1: {}
+
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
From 34a3f5618c5b5bbd1ef65cb0b7c1d67c98fc56c3 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Wed, 12 Nov 2025 20:25:59 -0600
Subject: [PATCH 67/77] chore(main): release 0.17.0 (#717)
---
CHANGELOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ce676740..812a1880 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## [0.17.0](https://github.com/Monadical-SAS/reflector/compare/v0.16.0...v0.17.0) (2025-11-13)
+
+
+### Features
+
+* add API key management UI ([#716](https://github.com/Monadical-SAS/reflector/issues/716)) ([372202b](https://github.com/Monadical-SAS/reflector/commit/372202b0e1a86823900b0aa77be1bfbc2893d8a1))
+* daily.co support as alternative to whereby ([#691](https://github.com/Monadical-SAS/reflector/issues/691)) ([1473fd8](https://github.com/Monadical-SAS/reflector/commit/1473fd82dc472c394cbaa2987212ad662a74bcac))
+
## [0.16.0](https://github.com/Monadical-SAS/reflector/compare/v0.15.0...v0.16.0) (2025-10-24)
From 857e035562f805af7d3dd753fe299a258bd2e449 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Thu, 13 Nov 2025 11:35:29 -0500
Subject: [PATCH 68/77] fix whereby reprocess logic branch (#720)
Co-authored-by: Igor Loskutov
---
server/reflector/views/transcripts_process.py | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/server/reflector/views/transcripts_process.py b/server/reflector/views/transcripts_process.py
index 46e070fd..cee1e10d 100644
--- a/server/reflector/views/transcripts_process.py
+++ b/server/reflector/views/transcripts_process.py
@@ -55,9 +55,18 @@ async def transcript_process(
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording:
bucket_name = recording.bucket_name
- track_keys = list(getattr(recording, "track_keys", []) or [])
+ 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 bucket_name:
+ if track_keys:
task_pipeline_multitrack_process.delay(
transcript_id=transcript_id,
bucket_name=bucket_name,
From a9a4f32324f66c838e081eee42bb9502f38c1db1 Mon Sep 17 00:00:00 2001
From: Sergey Mankovsky
Date: Fri, 14 Nov 2025 13:36:25 +0100
Subject: [PATCH 69/77] fix: copy transcript (#674)
* Copy transcript
* Fix share copy transcript
* Move copy button above transcript
---
.../transcripts/buildTranscriptWithTopics.ts | 60 ++++++++++++++++
www/app/(app)/transcripts/shareCopy.tsx | 15 ++--
www/app/(app)/transcripts/transcriptTitle.tsx | 68 +++++++++++++++++--
3 files changed, 130 insertions(+), 13 deletions(-)
create mode 100644 www/app/(app)/transcripts/buildTranscriptWithTopics.ts
diff --git a/www/app/(app)/transcripts/buildTranscriptWithTopics.ts b/www/app/(app)/transcripts/buildTranscriptWithTopics.ts
new file mode 100644
index 00000000..71553d31
--- /dev/null
+++ b/www/app/(app)/transcripts/buildTranscriptWithTopics.ts
@@ -0,0 +1,60 @@
+import type { components } from "../../reflector-api";
+import { formatTime } from "../../lib/time";
+
+type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
+type Participant = components["schemas"]["Participant"];
+
+function getSpeakerName(
+ speakerNumber: number,
+ participants?: Participant[] | null,
+): string {
+ const name = participants?.find((p) => p.speaker === speakerNumber)?.name;
+ return name && name.trim().length > 0 ? name : `Speaker ${speakerNumber}`;
+}
+
+export function buildTranscriptWithTopics(
+ topics: GetTranscriptTopic[],
+ participants?: Participant[] | null,
+ transcriptTitle?: string | null,
+): string {
+ const blocks: string[] = [];
+
+ if (transcriptTitle && transcriptTitle.trim()) {
+ blocks.push(`# ${transcriptTitle.trim()}`);
+ blocks.push("");
+ }
+
+ for (const topic of topics) {
+ // Topic header
+ const topicTime = formatTime(Math.floor(topic.timestamp || 0));
+ const title = topic.title?.trim() || "Untitled Topic";
+ blocks.push(`## ${title} [${topicTime}]`);
+
+ if (topic.segments && topic.segments.length > 0) {
+ for (const seg of topic.segments) {
+ const ts = formatTime(Math.floor(seg.start || 0));
+ const speaker = getSpeakerName(seg.speaker as number, participants);
+ const text = (seg.text || "").replace(/\s+/g, " ").trim();
+ if (text) {
+ blocks.push(`[${ts}] ${speaker}: ${text}`);
+ }
+ }
+ } else if (topic.transcript) {
+ // Fallback: plain transcript when segments are not present
+ const text = topic.transcript.replace(/\s+/g, " ").trim();
+ if (text) {
+ blocks.push(text);
+ }
+ }
+
+ // Blank line between topics
+ blocks.push("");
+ }
+
+ // Trim trailing blank line
+ while (blocks.length > 0 && blocks[blocks.length - 1] === "") {
+ blocks.pop();
+ }
+
+ return blocks.join("\n");
+}
diff --git a/www/app/(app)/transcripts/shareCopy.tsx b/www/app/(app)/transcripts/shareCopy.tsx
index fb1b5f68..bdbff5f4 100644
--- a/www/app/(app)/transcripts/shareCopy.tsx
+++ b/www/app/(app)/transcripts/shareCopy.tsx
@@ -3,6 +3,8 @@ import type { components } from "../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { Button, BoxProps, Box } from "@chakra-ui/react";
+import { buildTranscriptWithTopics } from "./buildTranscriptWithTopics";
+import { useTranscriptParticipants } from "../../lib/apiHooks";
type ShareCopyProps = {
finalSummaryElement: HTMLDivElement | null;
@@ -18,6 +20,7 @@ export default function ShareCopy({
}: ShareCopyProps & BoxProps) {
const [isCopiedSummary, setIsCopiedSummary] = useState(false);
const [isCopiedTranscript, setIsCopiedTranscript] = useState(false);
+ const participantsQuery = useTranscriptParticipants(transcript?.id || null);
const onCopySummaryClick = () => {
const text_to_copy = finalSummaryElement?.innerText;
@@ -32,12 +35,12 @@ export default function ShareCopy({
};
const onCopyTranscriptClick = () => {
- let text_to_copy =
- topics
- ?.map((topic) => topic.transcript)
- .join("\n\n")
- .replace(/ +/g, " ")
- .trim() || "";
+ const text_to_copy =
+ buildTranscriptWithTopics(
+ topics || [],
+ participantsQuery?.data || null,
+ transcript?.title || null,
+ ) || "";
text_to_copy &&
navigator.clipboard.writeText(text_to_copy).then(() => {
diff --git a/www/app/(app)/transcripts/transcriptTitle.tsx b/www/app/(app)/transcripts/transcriptTitle.tsx
index 1ac32b02..49a22c71 100644
--- a/www/app/(app)/transcripts/transcriptTitle.tsx
+++ b/www/app/(app)/transcripts/transcriptTitle.tsx
@@ -4,10 +4,15 @@ import type { components } from "../../reflector-api";
type UpdateTranscript = components["schemas"]["UpdateTranscript"];
type GetTranscript = components["schemas"]["GetTranscript"];
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
-import { useTranscriptUpdate } from "../../lib/apiHooks";
+import {
+ useTranscriptUpdate,
+ useTranscriptParticipants,
+} from "../../lib/apiHooks";
import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react";
-import { LuPen } from "react-icons/lu";
+import { LuPen, LuCopy, LuCheck } from "react-icons/lu";
import ShareAndPrivacy from "./shareAndPrivacy";
+import { buildTranscriptWithTopics } from "./buildTranscriptWithTopics";
+import { toaster } from "../../components/ui/toaster";
type TranscriptTitle = {
title: string;
@@ -25,6 +30,9 @@ const TranscriptTitle = (props: TranscriptTitle) => {
const [preEditTitle, setPreEditTitle] = useState(props.title);
const [isEditing, setIsEditing] = useState(false);
const updateTranscriptMutation = useTranscriptUpdate();
+ const participantsQuery = useTranscriptParticipants(
+ props.transcript?.id || null,
+ );
const updateTitle = async (newTitle: string, transcriptId: string) => {
try {
@@ -118,11 +126,57 @@ const TranscriptTitle = (props: TranscriptTitle) => {
{props.transcript && props.topics && (
-
+ <>
+ {
+ const text = buildTranscriptWithTopics(
+ props.topics || [],
+ participantsQuery?.data || null,
+ props.transcript?.title || null,
+ );
+ if (!text) return;
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ toaster
+ .create({
+ placement: "top",
+ duration: 2500,
+ render: () => (
+
+
+ Transcript copied
+
+
+ ),
+ })
+ .then(() => {});
+ })
+ .catch(() => {});
+ }}
+ >
+
+
+
+ >
)}
)}
From 28a7258e45317b78e60e6397be2bc503647eaace Mon Sep 17 00:00:00 2001
From: Sergey Mankovsky
Date: Fri, 14 Nov 2025 14:28:39 +0100
Subject: [PATCH 70/77] fix: add proccessing page to file upload and
reprocessing (#650)
---
.../(app)/transcripts/[transcriptId]/page.tsx | 59 ++++++++++-
.../[transcriptId]/processing/page.tsx | 97 +++++++++++++++++++
.../[transcriptId]/upload/page.tsx | 48 ++++-----
.../(app)/transcripts/fileUploadButton.tsx | 2 +
4 files changed, 171 insertions(+), 35 deletions(-)
create mode 100644 www/app/(app)/transcripts/[transcriptId]/processing/page.tsx
diff --git a/www/app/(app)/transcripts/[transcriptId]/page.tsx b/www/app/(app)/transcripts/[transcriptId]/page.tsx
index ec5f9ebb..1e020f1c 100644
--- a/www/app/(app)/transcripts/[transcriptId]/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/page.tsx
@@ -10,7 +10,15 @@ import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
import { useRouter } from "next/navigation";
-import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
+import {
+ Box,
+ Flex,
+ Grid,
+ GridItem,
+ Skeleton,
+ Text,
+ Spinner,
+} from "@chakra-ui/react";
import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
@@ -28,6 +36,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
"idle",
"recording",
"processing",
+ "uploaded",
] satisfies TranscriptStatus[] as TranscriptStatus[];
const transcript = useTranscriptGet(transcriptId);
@@ -45,15 +54,55 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useState(null);
useEffect(() => {
- if (waiting) {
- const newUrl = "/transcripts/" + params.transcriptId + "/record";
+ if (!waiting || !transcript.data) return;
+
+ const status = transcript.data.status;
+ let newUrl: string | null = null;
+
+ if (status === "processing" || status === "uploaded") {
+ newUrl = `/transcripts/${params.transcriptId}/processing`;
+ } else if (status === "recording") {
+ newUrl = `/transcripts/${params.transcriptId}/record`;
+ } else if (status === "idle") {
+ newUrl =
+ transcript.data.source_kind === "file"
+ ? `/transcripts/${params.transcriptId}/upload`
+ : `/transcripts/${params.transcriptId}/record`;
+ }
+
+ if (newUrl) {
// Shallow redirection does not work on NextJS 13
// https://github.com/vercel/next.js/discussions/48110
// https://github.com/vercel/next.js/discussions/49540
router.replace(newUrl);
- // history.replaceState({}, "", newUrl);
}
- }, [waiting]);
+ }, [waiting, transcript.data?.status, transcript.data?.source_kind]);
+
+ if (waiting) {
+ return (
+
+
+
+
+
+ Loading transcript...
+
+
+
+
+ );
+ }
if (transcript.error || topics?.error) {
return (
diff --git a/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx b/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx
new file mode 100644
index 00000000..4422e077
--- /dev/null
+++ b/www/app/(app)/transcripts/[transcriptId]/processing/page.tsx
@@ -0,0 +1,97 @@
+"use client";
+import { useEffect, use } from "react";
+import {
+ Heading,
+ Text,
+ VStack,
+ Spinner,
+ Button,
+ Center,
+} from "@chakra-ui/react";
+import { useRouter } from "next/navigation";
+import { useTranscriptGet } from "../../../../lib/apiHooks";
+
+type TranscriptProcessing = {
+ params: Promise<{
+ transcriptId: string;
+ }>;
+};
+
+export default function TranscriptProcessing(details: TranscriptProcessing) {
+ const params = use(details.params);
+ const transcriptId = params.transcriptId;
+ const router = useRouter();
+
+ const transcript = useTranscriptGet(transcriptId);
+
+ useEffect(() => {
+ const status = transcript.data?.status;
+ if (!status) return;
+
+ if (status === "ended" || status === "error") {
+ router.replace(`/transcripts/${transcriptId}`);
+ } else if (status === "recording") {
+ router.replace(`/transcripts/${transcriptId}/record`);
+ } else if (status === "idle") {
+ const dest =
+ transcript.data?.source_kind === "file"
+ ? `/transcripts/${transcriptId}/upload`
+ : `/transcripts/${transcriptId}/record`;
+ router.replace(dest);
+ }
+ }, [
+ transcript.data?.status,
+ transcript.data?.source_kind,
+ router,
+ transcriptId,
+ ]);
+
+ if (transcript.isLoading) {
+ return (
+
+ Loading transcript...
+
+ );
+ }
+
+ if (transcript.error) {
+ return (
+
+ Transcript not found
+ We couldn't load this transcript.
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+
+
+ Processing recording
+
+
+ You can safely return to the library while your recording is being
+ processed.
+
+ {
+ router.push("/browse");
+ }}
+ >
+ Browse
+
+
+
+
+ >
+ );
+}
diff --git a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
index b4bc25cc..9fc6a687 100644
--- a/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
+++ b/www/app/(app)/transcripts/[transcriptId]/upload/page.tsx
@@ -4,7 +4,7 @@ import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation";
import useMp3 from "../../useMp3";
-import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react";
+import { Center, VStack, Text, Heading } from "@chakra-ui/react";
import FileUploadButton from "../../fileUploadButton";
import { useTranscriptGet } from "../../../../lib/apiHooks";
@@ -53,6 +53,12 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const newUrl = "/transcripts/" + params.transcriptId;
router.replace(newUrl);
+ } else if (
+ newStatus &&
+ (newStatus == "uploaded" || newStatus == "processing")
+ ) {
+ // After upload finishes (or if already processing), redirect to the unified processing page
+ router.replace(`/transcripts/${params.transcriptId}/processing`);
}
}, [webSockets.status?.value, transcript.data?.status]);
@@ -71,7 +77,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
<>
{
Upload meeting
- {status && status == "idle" && (
- <>
-
- Please select the file, supported formats: .mp3, m4a, .wav,
- .mp4, .mov or .webm
-
-
- >
- )}
- {status && status == "uploaded" && (
- File is uploaded, processing...
- )}
- {(status == "recording" || status == "processing") && (
- <>
- Processing your recording...
-
- You can safely return to the library while your file is being
- processed.
-
- {
- router.push("/browse");
- }}
- >
- Browse
-
- >
- )}
+
+ Please select the file, supported formats: .mp3, m4a, .wav, .mp4,
+ .mov or .webm
+
+
+ router.replace(`/transcripts/${params.transcriptId}/processing`)
+ }
+ />
diff --git a/www/app/(app)/transcripts/fileUploadButton.tsx b/www/app/(app)/transcripts/fileUploadButton.tsx
index 1f5d72eb..b5fda7b6 100644
--- a/www/app/(app)/transcripts/fileUploadButton.tsx
+++ b/www/app/(app)/transcripts/fileUploadButton.tsx
@@ -5,6 +5,7 @@ import { useError } from "../../(errors)/errorContext";
type FileUploadButton = {
transcriptId: string;
+ onUploadComplete?: () => void;
};
export default function FileUploadButton(props: FileUploadButton) {
@@ -31,6 +32,7 @@ export default function FileUploadButton(props: FileUploadButton) {
const uploadNextChunk = async () => {
if (chunkNumber == totalChunks) {
setProgress(0);
+ props.onUploadComplete?.();
return;
}
From b20cad76e69fb6a76405af299a005f1ddcf60eae Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Fri, 14 Nov 2025 14:31:52 -0500
Subject: [PATCH 71/77] feat: daily QOL: participants dictionary (#721)
* daily QOL: participants dictionary
* meeting deactivation fix
* meeting deactivation fix
---------
Co-authored-by: Igor Loskutov
---
...aa_add_daily_participant_session_table_.py | 79 ++++++++
server/reflector/db/__init__.py | 1 +
.../db/daily_participant_sessions.py | 169 ++++++++++++++++++
server/reflector/video_platforms/base.py | 7 +-
server/reflector/video_platforms/daily.py | 93 ++++++++--
server/reflector/video_platforms/models.py | 28 ++-
server/reflector/video_platforms/whereby.py | 48 ++++-
server/reflector/views/daily.py | 154 ++++++++++++++--
server/reflector/worker/ics_sync.py | 2 +-
server/reflector/worker/process.py | 12 +-
server/scripts/list_daily_webhooks.py | 91 ++++++++++
server/tests/mocks/mock_platform.py | 24 ++-
server/tests/test_transcripts_process.py | 111 ++++++++++++
13 files changed, 759 insertions(+), 60 deletions(-)
create mode 100644 server/migrations/versions/2b92a1b03caa_add_daily_participant_session_table_.py
create mode 100644 server/reflector/db/daily_participant_sessions.py
create mode 100755 server/scripts/list_daily_webhooks.py
diff --git a/server/migrations/versions/2b92a1b03caa_add_daily_participant_session_table_.py b/server/migrations/versions/2b92a1b03caa_add_daily_participant_session_table_.py
new file mode 100644
index 00000000..90c3e94e
--- /dev/null
+++ b/server/migrations/versions/2b92a1b03caa_add_daily_participant_session_table_.py
@@ -0,0 +1,79 @@
+"""add daily participant session table with immutable left_at
+
+Revision ID: 2b92a1b03caa
+Revises: f8294b31f022
+Create Date: 2025-11-13 20:29:30.486577
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "2b92a1b03caa"
+down_revision: Union[str, None] = "f8294b31f022"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Create table
+ op.create_table(
+ "daily_participant_session",
+ sa.Column("id", sa.String(), nullable=False),
+ sa.Column("meeting_id", sa.String(), nullable=False),
+ sa.Column("room_id", sa.String(), nullable=False),
+ sa.Column("session_id", sa.String(), nullable=False),
+ sa.Column("user_id", sa.String(), nullable=True),
+ sa.Column("user_name", sa.String(), nullable=False),
+ sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("left_at", sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(["meeting_id"], ["meeting.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["room_id"], ["room.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ with op.batch_alter_table("daily_participant_session", schema=None) as batch_op:
+ batch_op.create_index(
+ "idx_daily_session_meeting_left", ["meeting_id", "left_at"], unique=False
+ )
+ batch_op.create_index("idx_daily_session_room", ["room_id"], unique=False)
+
+ # Create trigger function to prevent left_at from being updated once set
+ op.execute("""
+ CREATE OR REPLACE FUNCTION prevent_left_at_update()
+ RETURNS TRIGGER AS $$
+ BEGIN
+ IF OLD.left_at IS NOT NULL THEN
+ RAISE EXCEPTION 'left_at is immutable once set';
+ END IF;
+ RETURN NEW;
+ END;
+ $$ LANGUAGE plpgsql;
+ """)
+
+ # Create trigger
+ op.execute("""
+ CREATE TRIGGER prevent_left_at_update_trigger
+ BEFORE UPDATE ON daily_participant_session
+ FOR EACH ROW
+ EXECUTE FUNCTION prevent_left_at_update();
+ """)
+
+
+def downgrade() -> None:
+ # Drop trigger
+ op.execute(
+ "DROP TRIGGER IF EXISTS prevent_left_at_update_trigger ON daily_participant_session;"
+ )
+
+ # Drop trigger function
+ op.execute("DROP FUNCTION IF EXISTS prevent_left_at_update();")
+
+ # Drop indexes and table
+ with op.batch_alter_table("daily_participant_session", schema=None) as batch_op:
+ batch_op.drop_index("idx_daily_session_room")
+ batch_op.drop_index("idx_daily_session_meeting_left")
+
+ op.drop_table("daily_participant_session")
diff --git a/server/reflector/db/__init__.py b/server/reflector/db/__init__.py
index 8822e6b0..91ed12ee 100644
--- a/server/reflector/db/__init__.py
+++ b/server/reflector/db/__init__.py
@@ -25,6 +25,7 @@ def get_database() -> databases.Database:
# import models
import reflector.db.calendar_events # noqa
+import reflector.db.daily_participant_sessions # noqa
import reflector.db.meetings # noqa
import reflector.db.recordings # noqa
import reflector.db.rooms # noqa
diff --git a/server/reflector/db/daily_participant_sessions.py b/server/reflector/db/daily_participant_sessions.py
new file mode 100644
index 00000000..5fac1912
--- /dev/null
+++ b/server/reflector/db/daily_participant_sessions.py
@@ -0,0 +1,169 @@
+"""Daily.co participant session tracking.
+
+Stores webhook data for participant.joined and participant.left events to provide
+historical session information (Daily.co API only returns current participants).
+"""
+
+from datetime import datetime
+
+import sqlalchemy as sa
+from pydantic import BaseModel
+from sqlalchemy.dialects.postgresql import insert
+
+from reflector.db import get_database, metadata
+from reflector.utils.string import NonEmptyString
+
+daily_participant_sessions = sa.Table(
+ "daily_participant_session",
+ metadata,
+ sa.Column("id", sa.String, primary_key=True),
+ sa.Column(
+ "meeting_id",
+ sa.String,
+ sa.ForeignKey("meeting.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "room_id",
+ sa.String,
+ sa.ForeignKey("room.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("session_id", sa.String, nullable=False),
+ sa.Column("user_id", sa.String, nullable=True),
+ sa.Column("user_name", sa.String, nullable=False),
+ sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False),
+ sa.Column("left_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Index("idx_daily_session_meeting_left", "meeting_id", "left_at"),
+ sa.Index("idx_daily_session_room", "room_id"),
+)
+
+
+class DailyParticipantSession(BaseModel):
+ """Daily.co participant session record.
+
+ Tracks when a participant joined and left a meeting. Populated from webhooks:
+ - participant.joined: Creates record with left_at=None
+ - participant.left: Updates record with left_at
+
+ ID format: {meeting_id}:{user_id}:{joined_at_ms}
+ - Ensures idempotency (duplicate webhooks don't create duplicates)
+ - Allows same user to rejoin (different joined_at = different session)
+
+ Duration is calculated as: left_at - joined_at (not stored)
+ """
+
+ id: NonEmptyString
+ meeting_id: NonEmptyString
+ room_id: NonEmptyString
+ session_id: NonEmptyString # Daily.co's session_id (identifies room session)
+ user_id: NonEmptyString | None = None
+ user_name: str
+ joined_at: datetime
+ left_at: datetime | None = None
+
+
+class DailyParticipantSessionController:
+ """Controller for Daily.co participant session persistence."""
+
+ async def get_by_id(self, id: str) -> DailyParticipantSession | None:
+ """Get a session by its ID."""
+ query = daily_participant_sessions.select().where(
+ daily_participant_sessions.c.id == id
+ )
+ result = await get_database().fetch_one(query)
+ return DailyParticipantSession(**result) if result else None
+
+ async def get_open_session(
+ self, meeting_id: NonEmptyString, session_id: NonEmptyString
+ ) -> DailyParticipantSession | None:
+ """Get the open (not left) session for a user in a meeting."""
+ query = daily_participant_sessions.select().where(
+ sa.and_(
+ daily_participant_sessions.c.meeting_id == meeting_id,
+ daily_participant_sessions.c.session_id == session_id,
+ daily_participant_sessions.c.left_at.is_(None),
+ )
+ )
+ results = await get_database().fetch_all(query)
+
+ if len(results) > 1:
+ raise ValueError(
+ f"Multiple open sessions for daily session {session_id} in meeting {meeting_id}: "
+ f"found {len(results)} sessions"
+ )
+
+ return DailyParticipantSession(**results[0]) if results else None
+
+ async def upsert_joined(self, session: DailyParticipantSession) -> None:
+ """Insert or update when participant.joined webhook arrives.
+
+ Idempotent: Duplicate webhooks with same ID are safely ignored.
+ Out-of-order: If left webhook arrived first, preserves left_at.
+ """
+ query = insert(daily_participant_sessions).values(**session.model_dump())
+ query = query.on_conflict_do_update(
+ index_elements=["id"],
+ set_={"user_name": session.user_name},
+ )
+ await get_database().execute(query)
+
+ async def upsert_left(self, session: DailyParticipantSession) -> None:
+ """Update session when participant.left webhook arrives.
+
+ Finds the open session for this user in this meeting and updates left_at.
+ Works around Daily.co webhook timestamp inconsistency (joined_at differs by ~4ms between webhooks).
+
+ Handles three cases:
+ 1. Normal flow: open session exists → updates left_at
+ 2. Out-of-order: left arrives first → creates new record with left data
+ 3. Duplicate: left arrives again → idempotent (DB trigger prevents left_at modification)
+ """
+ if session.left_at is None:
+ raise ValueError("left_at is required for upsert_left")
+
+ if session.left_at <= session.joined_at:
+ raise ValueError(
+ f"left_at ({session.left_at}) must be after joined_at ({session.joined_at})"
+ )
+
+ # Find existing open session (works around timestamp mismatch in webhooks)
+ existing = await self.get_open_session(session.meeting_id, session.session_id)
+
+ if existing:
+ # Update existing open session
+ query = (
+ daily_participant_sessions.update()
+ .where(daily_participant_sessions.c.id == existing.id)
+ .values(left_at=session.left_at)
+ )
+ await get_database().execute(query)
+ else:
+ # Out-of-order or first webhook: insert new record
+ query = insert(daily_participant_sessions).values(**session.model_dump())
+ query = query.on_conflict_do_nothing(index_elements=["id"])
+ await get_database().execute(query)
+
+ async def get_by_meeting(self, meeting_id: str) -> list[DailyParticipantSession]:
+ """Get all participant sessions for a meeting (active and ended)."""
+ query = daily_participant_sessions.select().where(
+ daily_participant_sessions.c.meeting_id == meeting_id
+ )
+ results = await get_database().fetch_all(query)
+ return [DailyParticipantSession(**result) for result in results]
+
+ async def get_active_by_meeting(
+ self, meeting_id: str
+ ) -> list[DailyParticipantSession]:
+ """Get only active (not left) participant sessions for a meeting."""
+ query = daily_participant_sessions.select().where(
+ sa.and_(
+ daily_participant_sessions.c.meeting_id == meeting_id,
+ daily_participant_sessions.c.left_at.is_(None),
+ )
+ )
+ results = await get_database().fetch_all(query)
+ return [DailyParticipantSession(**result) for result in results]
+
+
+daily_participant_sessions_controller = DailyParticipantSessionController()
diff --git a/server/reflector/video_platforms/base.py b/server/reflector/video_platforms/base.py
index d208a75a..877114f7 100644
--- a/server/reflector/video_platforms/base.py
+++ b/server/reflector/video_platforms/base.py
@@ -1,10 +1,10 @@
from abc import ABC, abstractmethod
from datetime import datetime
-from typing import TYPE_CHECKING, Any, Dict, List, Optional
+from typing import TYPE_CHECKING, Any, Dict, Optional
from ..schemas.platform import Platform
from ..utils.string import NonEmptyString
-from .models import MeetingData, VideoPlatformConfig
+from .models import MeetingData, SessionData, VideoPlatformConfig
if TYPE_CHECKING:
from reflector.db.rooms import Room
@@ -26,7 +26,8 @@ class VideoPlatformClient(ABC):
pass
@abstractmethod
- async def get_room_sessions(self, room_name: str) -> List[Any] | None:
+ async def get_room_sessions(self, room_name: str) -> list[SessionData]:
+ """Get session history for a room."""
pass
@abstractmethod
diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py
index ec45d965..7bec4864 100644
--- a/server/reflector/video_platforms/daily.py
+++ b/server/reflector/video_platforms/daily.py
@@ -3,10 +3,13 @@ import hmac
from datetime import datetime
from hashlib import sha256
from http import HTTPStatus
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Optional
import httpx
+from reflector.db.daily_participant_sessions import (
+ daily_participant_sessions_controller,
+)
from reflector.db.rooms import Room
from reflector.logger import logger
from reflector.storage import get_dailyco_storage
@@ -15,7 +18,7 @@ from ..schemas.platform import Platform
from ..utils.daily import DailyRoomName
from ..utils.string import NonEmptyString
from .base import ROOM_PREFIX_SEPARATOR, VideoPlatformClient
-from .models import MeetingData, RecordingType, VideoPlatformConfig
+from .models import MeetingData, RecordingType, SessionData, VideoPlatformConfig
class DailyClient(VideoPlatformClient):
@@ -61,16 +64,16 @@ class DailyClient(VideoPlatformClient):
},
}
- # Get storage config for passing to Daily API
- daily_storage = get_dailyco_storage()
- assert daily_storage.bucket_name, "S3 bucket must be configured"
- data["properties"]["recordings_bucket"] = {
- "bucket_name": daily_storage.bucket_name,
- "bucket_region": daily_storage.region,
- "assume_role_arn": daily_storage.role_credential,
- "allow_api_access": True,
- }
-
+ # Only configure recordings_bucket if recording is enabled
+ if room.recording_type != self.RECORDING_NONE:
+ daily_storage = get_dailyco_storage()
+ assert daily_storage.bucket_name, "S3 bucket must be configured"
+ data["properties"]["recordings_bucket"] = {
+ "bucket_name": daily_storage.bucket_name,
+ "bucket_region": daily_storage.region,
+ "assume_role_arn": daily_storage.role_credential,
+ "allow_api_access": True,
+ }
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.BASE_URL}/rooms",
@@ -99,11 +102,49 @@ class DailyClient(VideoPlatformClient):
extra_data=result,
)
- async def get_room_sessions(self, room_name: str) -> List[Any] | None:
- # no such api
- return None
+ async def get_room_sessions(self, room_name: str) -> list[SessionData]:
+ """Get room session history from database (webhook-stored sessions).
+
+ Daily.co doesn't provide historical session API, so we query our database
+ where participant.joined/left webhooks are stored.
+ """
+ from reflector.db.meetings import meetings_controller
+
+ meeting = await meetings_controller.get_by_room_name(room_name)
+ if not meeting:
+ return []
+
+ sessions = await daily_participant_sessions_controller.get_by_meeting(
+ meeting.id
+ )
+
+ return [
+ SessionData(
+ session_id=s.id,
+ started_at=s.joined_at,
+ ended_at=s.left_at,
+ )
+ for s in sessions
+ ]
async def get_room_presence(self, room_name: str) -> Dict[str, Any]:
+ """Get room presence/session data for a Daily.co room.
+
+ Example response:
+ {
+ "total_count": 1,
+ "data": [
+ {
+ "room": "w2pp2cf4kltgFACPKXmX",
+ "id": "d61cd7b2-a273-42b4-89bd-be763fd562c1",
+ "userId": "pbZ+ismP7dk=",
+ "userName": "Moishe",
+ "joinTime": "2023-01-01T20:53:19.000Z",
+ "duration": 2312
+ }
+ ]
+ }
+ """
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.BASE_URL}/rooms/{room_name}/presence",
@@ -114,6 +155,28 @@ class DailyClient(VideoPlatformClient):
return response.json()
async def get_meeting_participants(self, meeting_id: str) -> Dict[str, Any]:
+ """Get participant data for a specific Daily.co meeting.
+
+ Example response:
+ {
+ "data": [
+ {
+ "user_id": "4q47OTmqa/w=",
+ "participant_id": "d61cd7b2-a273-42b4-89bd-be763fd562c1",
+ "user_name": "Lindsey",
+ "join_time": 1672786813,
+ "duration": 150
+ },
+ {
+ "user_id": "pbZ+ismP7dk=",
+ "participant_id": "b3d56359-14d7-46af-ac8b-18f8c991f5f6",
+ "user_name": "Moishe",
+ "join_time": 1672786797,
+ "duration": 165
+ }
+ ]
+ }
+ """
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.BASE_URL}/meetings/{meeting_id}/participants",
diff --git a/server/reflector/video_platforms/models.py b/server/reflector/video_platforms/models.py
index 82876888..648da251 100644
--- a/server/reflector/video_platforms/models.py
+++ b/server/reflector/video_platforms/models.py
@@ -1,18 +1,38 @@
+from datetime import datetime
from typing import Any, Dict, Literal, Optional
from pydantic import BaseModel, Field
from reflector.schemas.platform import WHEREBY_PLATFORM, Platform
+from reflector.utils.string import NonEmptyString
RecordingType = Literal["none", "local", "cloud"]
+class SessionData(BaseModel):
+ """Platform-agnostic session data.
+
+ Represents a participant session in a meeting room, regardless of platform.
+ Used to determine if a meeting is still active or has ended.
+ """
+
+ session_id: NonEmptyString = Field(description="Unique session identifier")
+ started_at: datetime = Field(description="When session started (UTC)")
+ ended_at: datetime | None = Field(
+ description="When session ended (UTC), None if still active"
+ )
+
+
class MeetingData(BaseModel):
platform: Platform
- meeting_id: str = Field(description="Platform-specific meeting identifier")
- room_url: str = Field(description="URL for participants to join")
- host_room_url: str = Field(description="URL for hosts (may be same as room_url)")
- room_name: str = Field(description="Human-readable room name")
+ meeting_id: NonEmptyString = Field(
+ description="Platform-specific meeting identifier"
+ )
+ room_url: NonEmptyString = Field(description="URL for participants to join")
+ host_room_url: NonEmptyString = Field(
+ description="URL for hosts (may be same as room_url)"
+ )
+ room_name: NonEmptyString = Field(description="Human-readable room name")
extra_data: Dict[str, Any] = Field(default_factory=dict)
class Config:
diff --git a/server/reflector/video_platforms/whereby.py b/server/reflector/video_platforms/whereby.py
index f856454a..f4775e89 100644
--- a/server/reflector/video_platforms/whereby.py
+++ b/server/reflector/video_platforms/whereby.py
@@ -4,7 +4,7 @@ import re
import time
from datetime import datetime
from hashlib import sha256
-from typing import Any, Dict, Optional
+from typing import Optional
import httpx
@@ -13,11 +13,8 @@ from reflector.storage import get_whereby_storage
from ..schemas.platform import WHEREBY_PLATFORM, Platform
from ..utils.string import NonEmptyString
-from .base import (
- MeetingData,
- VideoPlatformClient,
- VideoPlatformConfig,
-)
+from .base import VideoPlatformClient
+from .models import MeetingData, SessionData, VideoPlatformConfig
from .whereby_utils import whereby_room_name_prefix
@@ -80,15 +77,50 @@ class WherebyClient(VideoPlatformClient):
extra_data=result,
)
- async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
+ async def get_room_sessions(self, room_name: str) -> list[SessionData]:
+ """Get room session history from Whereby API.
+
+ Whereby API returns: [{"sessionId": "...", "startedAt": "...", "endedAt": "..." | null}, ...]
+ """
async with httpx.AsyncClient() as client:
+ """
+ {
+ "cursor": "text",
+ "results": [
+ {
+ "roomSessionId": "e2f29530-46ec-4cee-8b27-e565cb5bb2e9",
+ "roomName": "/room-prefix-793e9ec1-c686-423d-9043-9b7a10c553fd",
+ "startedAt": "2025-01-01T00:00:00.000Z",
+ "endedAt": "2025-01-01T01:00:00.000Z",
+ "totalParticipantMinutes": 124,
+ "totalRecorderMinutes": 120,
+ "totalStreamerMinutes": 120,
+ "totalUniqueParticipants": 4,
+ "totalUniqueRecorders": 3,
+ "totalUniqueStreamers": 2
+ }
+ ]
+ }"""
response = await client.get(
f"{self.config.api_url}/insights/room-sessions?roomName={room_name}",
headers=self.headers,
timeout=self.TIMEOUT,
)
response.raise_for_status()
- return response.json().get("results", [])
+ results = response.json().get("results", [])
+
+ return [
+ SessionData(
+ session_id=s["roomSessionId"],
+ started_at=datetime.fromisoformat(
+ s["startedAt"].replace("Z", "+00:00")
+ ),
+ ended_at=datetime.fromisoformat(s["endedAt"].replace("Z", "+00:00"))
+ if s.get("endedAt")
+ else None,
+ )
+ for s in results
+ ]
async def delete_room(self, room_name: str) -> bool:
return True
diff --git a/server/reflector/views/daily.py b/server/reflector/views/daily.py
index 6f51cd1e..baad97e9 100644
--- a/server/reflector/views/daily.py
+++ b/server/reflector/views/daily.py
@@ -1,9 +1,15 @@
import json
+from datetime import datetime, timezone
from typing import Any, Dict, Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
+from reflector.db import get_database
+from reflector.db.daily_participant_sessions import (
+ DailyParticipantSession,
+ daily_participant_sessions_controller,
+)
from reflector.db.meetings import meetings_controller
from reflector.logger import logger as _logger
from reflector.settings import settings
@@ -44,6 +50,24 @@ def _extract_room_name(event: DailyWebhookEvent) -> DailyRoomName | None:
async def webhook(request: Request):
"""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
"""
@@ -103,6 +127,32 @@ async def webhook(request: Request):
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:
@@ -110,29 +160,111 @@ async def _handle_participant_joined(event: DailyWebhookEvent):
return
meeting = await meetings_controller.get_by_room_name(daily_room_name)
- if meeting:
- await meetings_controller.increment_num_clients(meeting.id)
- logger.info(
- "Participant joined",
- meeting_id=meeting.id,
- room_name=daily_room_name,
- recording_type=meeting.recording_type,
- recording_trigger=meeting.recording_trigger,
- )
- else:
+ if not meeting:
logger.warning(
"participant.joined: meeting not found", room_name=daily_room_name
)
+ return
+
+ payload = event.payload
+ logger.warning({"payload": 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)
if not room_name:
+ logger.warning("participant.left: no room in payload", payload=event.payload)
return
meeting = await meetings_controller.get_by_room_name(room_name)
- if meeting:
+ if not meeting:
+ logger.warning("participant.left: 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)
+
+ logger.info(
+ "Participant left",
+ meeting_id=meeting.id,
+ room_name=room_name,
+ user_id=payload.get("user_id", None),
+ duration=payload.get("duration"),
+ session_id=session_id,
+ )
async def _handle_recording_started(event: DailyWebhookEvent):
diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py
index 4d72d4ae..6881dfa2 100644
--- a/server/reflector/worker/ics_sync.py
+++ b/server/reflector/worker/ics_sync.py
@@ -107,7 +107,7 @@ async def create_upcoming_meetings_for_event(event, create_window, room: Room):
client = create_platform_client(get_platform(room.platform))
meeting_data = await client.create_meeting(
- "",
+ room.name,
end_date=end_date,
room=room,
)
diff --git a/server/reflector/worker/process.py b/server/reflector/worker/process.py
index 47cbb1cb..dd9c1059 100644
--- a/server/reflector/worker/process.py
+++ b/server/reflector/worker/process.py
@@ -335,15 +335,15 @@ async def process_meetings():
Uses distributed locking to prevent race conditions when multiple workers
process the same meeting simultaneously.
"""
- logger.debug("Processing meetings")
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)
@@ -359,21 +359,23 @@ async def process_meetings():
if end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
- # This API call could be slow, extend lock if needed
client = create_platform_client(meeting.platform)
room_sessions = await client.get_room_sessions(meeting.room_name)
try:
- # Extend lock after slow operation to ensure we still hold it
+ # 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(
- rs["endedAt"] is None for rs in room_sessions
+ 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")
diff --git a/server/scripts/list_daily_webhooks.py b/server/scripts/list_daily_webhooks.py
new file mode 100755
index 00000000..c3c13568
--- /dev/null
+++ b/server/scripts/list_daily_webhooks.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python3
+
+import asyncio
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+import httpx
+
+from reflector.settings import settings
+
+
+async def list_webhooks():
+ """
+ List all Daily.co webhooks for this account.
+ """
+ if not settings.DAILY_API_KEY:
+ print("Error: DAILY_API_KEY not set")
+ return 1
+
+ headers = {
+ "Authorization": f"Bearer {settings.DAILY_API_KEY}",
+ "Content-Type": "application/json",
+ }
+
+ async with httpx.AsyncClient() as client:
+ try:
+ """
+ Daily.co webhook list response format:
+ [
+ {
+ "uuid": "0b4e4c7c-5eaf-46fe-990b-a3752f5684f5",
+ "url": "{{webhook_url}}",
+ "hmac": "NQrSA5z0FkJ44QPrFerW7uCc5kdNLv3l2FDEKDanL1U=",
+ "basicAuth": null,
+ "eventTypes": [
+ "recording.started",
+ "recording.ready-to-download"
+ ],
+ "state": "ACTVIE",
+ "failedCount": 0,
+ "lastMomentPushed": "2023-08-15T18:29:52.000Z",
+ "domainId": "{{domain_id}}",
+ "createdAt": "2023-08-15T18:28:30.000Z",
+ "updatedAt": "2023-08-15T18:29:52.000Z"
+ }
+ ]
+ """
+ resp = await client.get(
+ "https://api.daily.co/v1/webhooks",
+ headers=headers,
+ )
+ resp.raise_for_status()
+ webhooks = resp.json()
+
+ if not webhooks:
+ print("No webhooks found")
+ return 0
+
+ print(f"Found {len(webhooks)} webhook(s):\n")
+
+ for webhook in webhooks:
+ print("=" * 80)
+ print(f"UUID: {webhook['uuid']}")
+ print(f"URL: {webhook['url']}")
+ print(f"State: {webhook['state']}")
+ print(f"Event Types: {', '.join(webhook.get('eventTypes', []))}")
+ print(
+ f"HMAC Secret: {'✓ Configured' if webhook.get('hmac') else '✗ Not set'}"
+ )
+ print()
+
+ print("=" * 80)
+ print(
+ f"\nCurrent DAILY_WEBHOOK_UUID in settings: {settings.DAILY_WEBHOOK_UUID or '(not set)'}"
+ )
+
+ return 0
+
+ except httpx.HTTPStatusError as e:
+ print(f"Error fetching webhooks: {e}")
+ print(f"Response: {e.response.text}")
+ return 1
+ except Exception as e:
+ print(f"Unexpected error: {e}")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(asyncio.run(list_webhooks()))
diff --git a/server/tests/mocks/mock_platform.py b/server/tests/mocks/mock_platform.py
index 0f84a271..b4d9ae90 100644
--- a/server/tests/mocks/mock_platform.py
+++ b/server/tests/mocks/mock_platform.py
@@ -3,9 +3,11 @@ from datetime import datetime
from typing import Any, Dict, Literal, Optional
from reflector.db.rooms import Room
+from reflector.utils.string import NonEmptyString
from reflector.video_platforms.base import (
ROOM_PREFIX_SEPARATOR,
MeetingData,
+ SessionData,
VideoPlatformClient,
VideoPlatformConfig,
)
@@ -49,22 +51,18 @@ class MockPlatformClient(VideoPlatformClient):
extra_data={"mock": True},
)
- async def get_room_sessions(self, room_name: str) -> Dict[str, Any]:
+ async def get_room_sessions(self, room_name: NonEmptyString) -> list[SessionData]:
if room_name not in self._rooms:
- return {"error": "Room not found"}
+ return []
room_data = self._rooms[room_name]
- return {
- "roomName": room_name,
- "sessions": [
- {
- "sessionId": room_data["id"],
- "startTime": datetime.utcnow().isoformat(),
- "participants": room_data["participants"],
- "isActive": room_data["is_active"],
- }
- ],
- }
+ return [
+ SessionData(
+ session_id=room_data["id"],
+ started_at=datetime.utcnow(),
+ ended_at=None if room_data["is_active"] else datetime.utcnow(),
+ )
+ ]
async def delete_room(self, room_name: str) -> bool:
if room_name in self._rooms:
diff --git a/server/tests/test_transcripts_process.py b/server/tests/test_transcripts_process.py
index 5f45cf4b..3a0614c1 100644
--- a/server/tests/test_transcripts_process.py
+++ b/server/tests/test_transcripts_process.py
@@ -1,5 +1,6 @@
import asyncio
import time
+from unittest.mock import patch
import pytest
from httpx import ASGITransport, AsyncClient
@@ -101,3 +102,113 @@ async def test_transcript_process(
assert response.status_code == 200
assert len(response.json()) == 1
assert "Hello world. How are you today?" in response.json()[0]["transcript"]
+
+
+@pytest.mark.usefixtures("setup_database")
+@pytest.mark.asyncio
+async def test_whereby_recording_uses_file_pipeline(client):
+ """Test that Whereby recordings (bucket_name but no track_keys) use file pipeline"""
+ from datetime import datetime, timezone
+
+ from reflector.db.recordings import Recording, recordings_controller
+ from reflector.db.transcripts import transcripts_controller
+
+ # Create transcript with Whereby recording (has bucket_name, no track_keys)
+ transcript = await transcripts_controller.add(
+ "",
+ source_kind="room",
+ source_language="en",
+ target_language="en",
+ user_id="test-user",
+ share_mode="public",
+ )
+
+ recording = await recordings_controller.create(
+ Recording(
+ bucket_name="whereby-bucket",
+ object_key="test-recording.mp4", # gitleaks:allow
+ meeting_id="test-meeting",
+ recorded_at=datetime.now(timezone.utc),
+ track_keys=None, # Whereby recordings have no track_keys
+ )
+ )
+
+ await transcripts_controller.update(
+ transcript, {"recording_id": recording.id, "status": "uploaded"}
+ )
+
+ with (
+ patch(
+ "reflector.views.transcripts_process.task_pipeline_file_process"
+ ) as mock_file_pipeline,
+ patch(
+ "reflector.views.transcripts_process.task_pipeline_multitrack_process"
+ ) as mock_multitrack_pipeline,
+ ):
+ response = await client.post(f"/transcripts/{transcript.id}/process")
+
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+ # Whereby recordings should use file pipeline
+ mock_file_pipeline.delay.assert_called_once_with(transcript_id=transcript.id)
+ mock_multitrack_pipeline.delay.assert_not_called()
+
+
+@pytest.mark.usefixtures("setup_database")
+@pytest.mark.asyncio
+async def test_dailyco_recording_uses_multitrack_pipeline(client):
+ """Test that Daily.co recordings (bucket_name + track_keys) use multitrack pipeline"""
+ from datetime import datetime, timezone
+
+ from reflector.db.recordings import Recording, recordings_controller
+ from reflector.db.transcripts import transcripts_controller
+
+ # Create transcript with Daily.co multitrack recording
+ transcript = await transcripts_controller.add(
+ "",
+ source_kind="room",
+ source_language="en",
+ target_language="en",
+ user_id="test-user",
+ share_mode="public",
+ )
+
+ track_keys = [
+ "recordings/test-room/track1.webm",
+ "recordings/test-room/track2.webm",
+ ]
+ recording = await recordings_controller.create(
+ Recording(
+ bucket_name="daily-bucket",
+ object_key="recordings/test-room",
+ meeting_id="test-meeting",
+ track_keys=track_keys,
+ recorded_at=datetime.now(timezone.utc),
+ )
+ )
+
+ await transcripts_controller.update(
+ transcript, {"recording_id": recording.id, "status": "uploaded"}
+ )
+
+ with (
+ patch(
+ "reflector.views.transcripts_process.task_pipeline_file_process"
+ ) as mock_file_pipeline,
+ patch(
+ "reflector.views.transcripts_process.task_pipeline_multitrack_process"
+ ) as mock_multitrack_pipeline,
+ ):
+ response = await client.post(f"/transcripts/{transcript.id}/process")
+
+ assert response.status_code == 200
+ assert response.json()["status"] == "ok"
+
+ # Daily.co multitrack recordings should use multitrack pipeline
+ mock_multitrack_pipeline.delay.assert_called_once_with(
+ transcript_id=transcript.id,
+ bucket_name="daily-bucket",
+ track_keys=track_keys,
+ )
+ mock_file_pipeline.delay.assert_not_called()
From 2801ab3643cc98c6397c9a9926cfa566498555e1 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Fri, 14 Nov 2025 15:10:26 -0600
Subject: [PATCH 72/77] chore(main): release 0.18.0 (#722)
---
CHANGELOG.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 812a1880..083f5b2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [0.18.0](https://github.com/Monadical-SAS/reflector/compare/v0.17.0...v0.18.0) (2025-11-14)
+
+
+### Features
+
+* daily QOL: participants dictionary ([#721](https://github.com/Monadical-SAS/reflector/issues/721)) ([b20cad7](https://github.com/Monadical-SAS/reflector/commit/b20cad76e69fb6a76405af299a005f1ddcf60eae))
+
+
+### Bug Fixes
+
+* add proccessing page to file upload and reprocessing ([#650](https://github.com/Monadical-SAS/reflector/issues/650)) ([28a7258](https://github.com/Monadical-SAS/reflector/commit/28a7258e45317b78e60e6397be2bc503647eaace))
+* copy transcript ([#674](https://github.com/Monadical-SAS/reflector/issues/674)) ([a9a4f32](https://github.com/Monadical-SAS/reflector/commit/a9a4f32324f66c838e081eee42bb9502f38c1db1))
+
## [0.17.0](https://github.com/Monadical-SAS/reflector/compare/v0.16.0...v0.17.0) (2025-11-13)
From 18ed7133693653ef4ddac6c659a8c14b320d1657 Mon Sep 17 00:00:00 2001
From: Mathieu Virbel
Date: Tue, 18 Nov 2025 09:15:29 -0600
Subject: [PATCH 73/77] fix: parakeet vad not getting the end timestamp (#728)
---
gpu/modal_deployments/reflector_transcriber_parakeet.py | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/gpu/modal_deployments/reflector_transcriber_parakeet.py b/gpu/modal_deployments/reflector_transcriber_parakeet.py
index 947fccca..5f326b77 100644
--- a/gpu/modal_deployments/reflector_transcriber_parakeet.py
+++ b/gpu/modal_deployments/reflector_transcriber_parakeet.py
@@ -81,9 +81,9 @@ image = (
"cuda-python==12.8.0",
"fastapi==0.115.12",
"numpy<2",
- "librosa==0.10.1",
+ "librosa==0.11.0",
"requests",
- "silero-vad==5.1.0",
+ "silero-vad==6.2.0",
"torch",
)
.entrypoint([]) # silence chatty logs by container on start
@@ -306,6 +306,7 @@ class TranscriberParakeetFile:
) -> Generator[TimeSegment, None, None]:
"""Generate speech segments using VAD with start/end sample indices"""
vad_iterator = VADIterator(self.vad_model, sampling_rate=SAMPLERATE)
+ audio_duration = len(audio_array) / float(SAMPLERATE)
window_size = VAD_CONFIG["window_size"]
start = None
@@ -332,6 +333,10 @@ class TranscriberParakeetFile:
yield TimeSegment(start_time, end_time)
start = None
+ if start is not None:
+ start_time = start / float(SAMPLERATE)
+ yield TimeSegment(start_time, audio_duration)
+
vad_iterator.reset_states()
def batch_speech_segments(
From 616092a9bb260e20732e81b406fbebf4b5421e6f Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Tue, 18 Nov 2025 10:40:46 -0500
Subject: [PATCH 74/77] keep only debug log for tracks with no words (#724)
Co-authored-by: Igor Loskutov
---
server/reflector/pipelines/main_multitrack_pipeline.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/server/reflector/pipelines/main_multitrack_pipeline.py b/server/reflector/pipelines/main_multitrack_pipeline.py
index addcd9b4..f91c8250 100644
--- a/server/reflector/pipelines/main_multitrack_pipeline.py
+++ b/server/reflector/pipelines/main_multitrack_pipeline.py
@@ -582,7 +582,8 @@ class PipelineMainMultitrack(PipelineMainBase):
t = await self.transcribe_file(padded_url, transcript.source_language)
if not t.words:
- continue
+ self.logger.debug(f"no words in track {idx}")
+ # not skipping, it may be silence or indistinguishable mumbling
for w in t.words:
w.speaker = idx
From 3e47c2c0573504858e0d2e1798b6ed31f16b4a5d Mon Sep 17 00:00:00 2001
From: Sergey Mankovsky
Date: Tue, 18 Nov 2025 21:04:32 +0100
Subject: [PATCH 75/77] fix: start raw tracks recording (#729)
* Start raw tracks recording
* Bring back recording properties
---
www/app/[roomName]/components/DailyRoom.tsx | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/www/app/[roomName]/components/DailyRoom.tsx b/www/app/[roomName]/components/DailyRoom.tsx
index 920f8624..cfefbf6a 100644
--- a/www/app/[roomName]/components/DailyRoom.tsx
+++ b/www/app/[roomName]/components/DailyRoom.tsx
@@ -60,6 +60,15 @@ export default function DailyRoom({ meeting }: DailyRoomProps) {
}
frame.on("left-meeting", handleLeave);
+
+ frame.on("joined-meeting", async () => {
+ try {
+ await frame.startRecording({ type: "raw-tracks" });
+ } catch (error) {
+ console.error("Failed to start recording:", error);
+ }
+ });
+
await frame.join({ url: roomUrl });
} catch (error) {
console.error("Error creating Daily frame:", error);
From 4287f8b8aeee60e51db7539f4dcbda5f6e696bd8 Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Fri, 21 Nov 2025 10:24:04 -0500
Subject: [PATCH 76/77] feat: dailyco api module (#725)
* 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
* remove generated daily docs
---------
Co-authored-by: Igor Loskutov
---
server/reflector/dailyco_api/README.md | 6 +
server/reflector/dailyco_api/__init__.py | 96 ++++
server/reflector/dailyco_api/client.py | 527 ++++++++++++++++++
server/reflector/dailyco_api/requests.py | 158 ++++++
server/reflector/dailyco_api/responses.py | 182 ++++++
server/reflector/dailyco_api/webhook_utils.py | 229 ++++++++
server/reflector/dailyco_api/webhooks.py | 199 +++++++
server/reflector/video_platforms/daily.py | 252 +++------
server/reflector/views/daily.py | 70 +--
server/scripts/list_daily_webhooks.py | 58 +-
server/scripts/recreate_daily_webhook.py | 84 +--
11 files changed, 1558 insertions(+), 303 deletions(-)
create mode 100644 server/reflector/dailyco_api/README.md
create mode 100644 server/reflector/dailyco_api/__init__.py
create mode 100644 server/reflector/dailyco_api/client.py
create mode 100644 server/reflector/dailyco_api/requests.py
create mode 100644 server/reflector/dailyco_api/responses.py
create mode 100644 server/reflector/dailyco_api/webhook_utils.py
create mode 100644 server/reflector/dailyco_api/webhooks.py
diff --git a/server/reflector/dailyco_api/README.md b/server/reflector/dailyco_api/README.md
new file mode 100644
index 00000000..88ec2cc3
--- /dev/null
+++ b/server/reflector/dailyco_api/README.md
@@ -0,0 +1,6 @@
+anything about Daily.co api interaction
+
+- webhook event shapes
+- REST api client
+
+No REST api client existing found in the wild; the official lib is about working with videocall as a bot
\ No newline at end of file
diff --git a/server/reflector/dailyco_api/__init__.py b/server/reflector/dailyco_api/__init__.py
new file mode 100644
index 00000000..1a65478b
--- /dev/null
+++ b/server/reflector/dailyco_api/__init__.py
@@ -0,0 +1,96 @@
+"""
+Daily.co API Module
+"""
+
+# Client
+from .client import DailyApiClient, DailyApiError
+
+# Request models
+from .requests import (
+ CreateMeetingTokenRequest,
+ CreateRoomRequest,
+ CreateWebhookRequest,
+ MeetingTokenProperties,
+ RecordingsBucketConfig,
+ RoomProperties,
+ UpdateWebhookRequest,
+)
+
+# Response models
+from .responses import (
+ MeetingParticipant,
+ MeetingParticipantsResponse,
+ MeetingResponse,
+ MeetingTokenResponse,
+ RecordingResponse,
+ RecordingS3Info,
+ RoomPresenceParticipant,
+ RoomPresenceResponse,
+ RoomResponse,
+ WebhookResponse,
+)
+
+# Webhook utilities
+from .webhook_utils import (
+ extract_room_name,
+ parse_participant_joined,
+ parse_participant_left,
+ parse_recording_error,
+ parse_recording_ready,
+ parse_recording_started,
+ parse_webhook_payload,
+ verify_webhook_signature,
+)
+
+# Webhook models
+from .webhooks import (
+ DailyTrack,
+ DailyWebhookEvent,
+ ParticipantJoinedPayload,
+ ParticipantLeftPayload,
+ RecordingErrorPayload,
+ RecordingReadyToDownloadPayload,
+ RecordingStartedPayload,
+)
+
+__all__ = [
+ # Client
+ "DailyApiClient",
+ "DailyApiError",
+ # Requests
+ "CreateRoomRequest",
+ "RoomProperties",
+ "RecordingsBucketConfig",
+ "CreateMeetingTokenRequest",
+ "MeetingTokenProperties",
+ "CreateWebhookRequest",
+ "UpdateWebhookRequest",
+ # Responses
+ "RoomResponse",
+ "RoomPresenceResponse",
+ "RoomPresenceParticipant",
+ "MeetingParticipantsResponse",
+ "MeetingParticipant",
+ "MeetingResponse",
+ "RecordingResponse",
+ "RecordingS3Info",
+ "MeetingTokenResponse",
+ "WebhookResponse",
+ # Webhooks
+ "DailyWebhookEvent",
+ "DailyTrack",
+ "ParticipantJoinedPayload",
+ "ParticipantLeftPayload",
+ "RecordingStartedPayload",
+ "RecordingReadyToDownloadPayload",
+ "RecordingErrorPayload",
+ # Webhook utilities
+ "verify_webhook_signature",
+ "extract_room_name",
+ "parse_webhook_payload",
+ "parse_participant_joined",
+ "parse_participant_left",
+ "parse_recording_started",
+ "parse_recording_ready",
+ "parse_recording_error",
+]
diff --git a/server/reflector/dailyco_api/client.py b/server/reflector/dailyco_api/client.py
new file mode 100644
index 00000000..24221bb2
--- /dev/null
+++ b/server/reflector/dailyco_api/client.py
@@ -0,0 +1,527 @@
+"""
+Daily.co API Client
+
+Complete async client for Daily.co REST API with Pydantic models.
+
+Reference: https://docs.daily.co/reference/rest-api
+"""
+
+from http import HTTPStatus
+from typing import Any
+
+import httpx
+import structlog
+
+from reflector.utils.string import NonEmptyString
+
+from .requests import (
+ CreateMeetingTokenRequest,
+ CreateRoomRequest,
+ CreateWebhookRequest,
+ UpdateWebhookRequest,
+)
+from .responses import (
+ MeetingParticipantsResponse,
+ MeetingResponse,
+ MeetingTokenResponse,
+ RecordingResponse,
+ RoomPresenceResponse,
+ RoomResponse,
+ WebhookResponse,
+)
+
+logger = structlog.get_logger(__name__)
+
+
+class DailyApiError(Exception):
+ """Daily.co API error with full request/response context."""
+
+ def __init__(self, operation: str, response: httpx.Response):
+ self.operation = operation
+ self.response = response
+ self.status_code = response.status_code
+ self.response_body = response.text
+ self.url = str(response.url)
+ self.request_body = (
+ response.request.content.decode() if response.request.content else None
+ )
+
+ super().__init__(
+ f"Daily.co API error: {operation} failed with status {self.status_code}"
+ )
+
+
+class DailyApiClient:
+ """
+ Complete async client for Daily.co REST API.
+
+ Usage:
+ # Direct usage
+ client = DailyApiClient(api_key="your_api_key")
+ room = await client.create_room(CreateRoomRequest(name="my-room"))
+ await client.close() # Clean up when done
+
+ # Context manager (recommended)
+ async with DailyApiClient(api_key="your_api_key") as client:
+ room = await client.create_room(CreateRoomRequest(name="my-room"))
+ """
+
+ BASE_URL = "https://api.daily.co/v1"
+ DEFAULT_TIMEOUT = 10.0
+
+ def __init__(
+ self,
+ api_key: NonEmptyString,
+ webhook_secret: NonEmptyString | None = None,
+ timeout: float = DEFAULT_TIMEOUT,
+ base_url: NonEmptyString | None = None,
+ ):
+ """
+ Initialize Daily.co API client.
+
+ Args:
+ api_key: Daily.co API key (Bearer token)
+ webhook_secret: Base64-encoded HMAC secret for webhook verification.
+ Must match the 'hmac' value provided when creating webhooks.
+ Generate with: base64.b64encode(os.urandom(32)).decode()
+ timeout: Default request timeout in seconds
+ base_url: Override base URL (for testing)
+ """
+ self.api_key = api_key
+ self.webhook_secret = webhook_secret
+ self.timeout = timeout
+ self.base_url = base_url or self.BASE_URL
+
+ self.headers = {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ }
+
+ self._client: httpx.AsyncClient | None = None
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ await self.close()
+
+ async def _get_client(self) -> httpx.AsyncClient:
+ if self._client is None:
+ self._client = httpx.AsyncClient(timeout=self.timeout)
+ return self._client
+
+ async def close(self):
+ if self._client is not None:
+ await self._client.aclose()
+ self._client = None
+
+ async def _handle_response(
+ self, response: httpx.Response, operation: str
+ ) -> dict[str, Any]:
+ """
+ Handle API response with error logging.
+
+ Args:
+ response: HTTP response
+ operation: Operation name for logging (e.g., "create_room")
+
+ Returns:
+ Parsed JSON response
+
+ Raises:
+ DailyApiError: If request failed with full context
+ """
+ if response.status_code >= 400:
+ logger.error(
+ f"Daily.co API error: {operation}",
+ status_code=response.status_code,
+ response_body=response.text,
+ request_body=response.request.content.decode()
+ if response.request.content
+ else None,
+ url=str(response.url),
+ )
+ raise DailyApiError(operation, response)
+
+ return response.json()
+
+ # ============================================================================
+ # ROOMS
+ # ============================================================================
+
+ async def create_room(self, request: CreateRoomRequest) -> RoomResponse:
+ """
+ Create a new Daily.co room.
+
+ Reference: https://docs.daily.co/reference/rest-api/rooms/create-room
+
+ Args:
+ request: Room creation request with name, privacy, and properties
+
+ Returns:
+ Created room data including URL and ID
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails
+ """
+ client = await self._get_client()
+ response = await client.post(
+ f"{self.base_url}/rooms",
+ headers=self.headers,
+ json=request.model_dump(exclude_none=True),
+ )
+
+ data = await self._handle_response(response, "create_room")
+ return RoomResponse(**data)
+
+ async def get_room(self, room_name: NonEmptyString) -> RoomResponse:
+ """
+ Get room configuration.
+
+ Args:
+ room_name: Daily.co room name
+
+ Returns:
+ Room configuration data
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails
+ """
+ client = await self._get_client()
+ response = await client.get(
+ f"{self.base_url}/rooms/{room_name}",
+ headers=self.headers,
+ )
+
+ data = await self._handle_response(response, "get_room")
+ return RoomResponse(**data)
+
+ async def get_room_presence(
+ self, room_name: NonEmptyString
+ ) -> RoomPresenceResponse:
+ """
+ Get current participants in a room (real-time presence).
+
+ Reference: https://docs.daily.co/reference/rest-api/rooms/get-room-presence
+
+ Args:
+ room_name: Daily.co room name
+
+ Returns:
+ List of currently present participants with join time and duration
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails
+ """
+ client = await self._get_client()
+ response = await client.get(
+ f"{self.base_url}/rooms/{room_name}/presence",
+ headers=self.headers,
+ )
+
+ data = await self._handle_response(response, "get_room_presence")
+ return RoomPresenceResponse(**data)
+
+ async def delete_room(self, room_name: NonEmptyString) -> None:
+ """
+ Delete a room (idempotent - succeeds even if room doesn't exist).
+
+ Reference: https://docs.daily.co/reference/rest-api/rooms/delete-room
+
+ Args:
+ room_name: Daily.co room name
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails (except 404)
+ """
+ client = await self._get_client()
+ response = await client.delete(
+ f"{self.base_url}/rooms/{room_name}",
+ headers=self.headers,
+ )
+
+ # Idempotent delete - 404 means already deleted
+ if response.status_code == HTTPStatus.NOT_FOUND:
+ logger.debug("Room not found (already deleted)", room_name=room_name)
+ return
+
+ await self._handle_response(response, "delete_room")
+
+ # ============================================================================
+ # MEETINGS
+ # ============================================================================
+
+ async def get_meeting(self, meeting_id: NonEmptyString) -> MeetingResponse:
+ """
+ Get full meeting information including participants.
+
+ Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-information
+
+ Args:
+ meeting_id: Daily.co meeting/session ID
+
+ Returns:
+ Meeting metadata including room, duration, participants, and status
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails
+ """
+ client = await self._get_client()
+ response = await client.get(
+ f"{self.base_url}/meetings/{meeting_id}",
+ headers=self.headers,
+ )
+
+ data = await self._handle_response(response, "get_meeting")
+ return MeetingResponse(**data)
+
+ async def get_meeting_participants(
+ self,
+ meeting_id: NonEmptyString,
+ limit: int | None = None,
+ joined_after: NonEmptyString | None = None,
+ joined_before: NonEmptyString | None = None,
+ ) -> MeetingParticipantsResponse:
+ """
+ Get historical participant data from a completed meeting (paginated).
+
+ Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants
+
+ Args:
+ meeting_id: Daily.co meeting/session ID
+ limit: Maximum number of participant records to return
+ joined_after: Return participants who joined after this participant_id
+ joined_before: Return participants who joined before this participant_id
+
+ Returns:
+ List of participants with join times and duration
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails (404 when no more participants)
+
+ Note:
+ For pagination, use joined_after with the last participant_id from previous response.
+ Returns 404 when no more participants remain.
+ """
+ params = {}
+ if limit is not None:
+ params["limit"] = limit
+ if joined_after is not None:
+ params["joined_after"] = joined_after
+ if joined_before is not None:
+ params["joined_before"] = joined_before
+
+ client = await self._get_client()
+ response = await client.get(
+ f"{self.base_url}/meetings/{meeting_id}/participants",
+ headers=self.headers,
+ params=params,
+ )
+
+ data = await self._handle_response(response, "get_meeting_participants")
+ return MeetingParticipantsResponse(**data)
+
+ # ============================================================================
+ # RECORDINGS
+ # ============================================================================
+
+ async def get_recording(self, recording_id: NonEmptyString) -> RecordingResponse:
+ """
+ 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(
+ f"{self.base_url}/recordings/{recording_id}",
+ headers=self.headers,
+ )
+
+ data = await self._handle_response(response, "get_recording")
+ return RecordingResponse(**data)
+
+ # ============================================================================
+ # MEETING TOKENS
+ # ============================================================================
+
+ async def create_meeting_token(
+ self, request: CreateMeetingTokenRequest
+ ) -> MeetingTokenResponse:
+ """
+ Create a meeting token for participant authentication.
+
+ Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token
+
+ Args:
+ request: Token properties including room name, user_id, permissions
+
+ Returns:
+ JWT meeting token
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails
+ """
+ client = await self._get_client()
+ response = await client.post(
+ f"{self.base_url}/meeting-tokens",
+ headers=self.headers,
+ json=request.model_dump(exclude_none=True),
+ )
+
+ data = await self._handle_response(response, "create_meeting_token")
+ return MeetingTokenResponse(**data)
+
+ # ============================================================================
+ # WEBHOOKS
+ # ============================================================================
+
+ async def list_webhooks(self) -> list[WebhookResponse]:
+ """
+ List all configured webhooks for this account.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+
+ Returns:
+ List of webhook configurations
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails
+ """
+ client = await self._get_client()
+ response = await client.get(
+ f"{self.base_url}/webhooks",
+ headers=self.headers,
+ )
+
+ data = await self._handle_response(response, "list_webhooks")
+
+ # Daily.co returns array directly (not paginated)
+ if isinstance(data, list):
+ return [WebhookResponse(**wh) for wh in data]
+
+ # Future-proof: handle potential pagination envelope
+ if isinstance(data, dict) and "data" in data:
+ return [WebhookResponse(**wh) for wh in data["data"]]
+
+ logger.warning("Unexpected webhook list response format", data=data)
+ return []
+
+ async def create_webhook(self, request: CreateWebhookRequest) -> WebhookResponse:
+ """
+ Create a new webhook subscription.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+
+ Args:
+ request: Webhook configuration with URL, event types, and HMAC secret
+
+ Returns:
+ Created webhook with UUID and state
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails
+ """
+ client = await self._get_client()
+ response = await client.post(
+ f"{self.base_url}/webhooks",
+ headers=self.headers,
+ json=request.model_dump(exclude_none=True),
+ )
+
+ data = await self._handle_response(response, "create_webhook")
+ return WebhookResponse(**data)
+
+ async def update_webhook(
+ self, webhook_uuid: NonEmptyString, request: UpdateWebhookRequest
+ ) -> WebhookResponse:
+ """
+ Update webhook configuration.
+
+ Note: Daily.co may not support PATCH for all fields.
+ Common pattern is delete + recreate.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+
+ Args:
+ webhook_uuid: Webhook UUID to update
+ request: Updated webhook configuration
+
+ Returns:
+ Updated webhook configuration
+
+ Raises:
+ httpx.HTTPStatusError: If API request fails
+ """
+ client = await self._get_client()
+ response = await client.patch(
+ f"{self.base_url}/webhooks/{webhook_uuid}",
+ headers=self.headers,
+ json=request.model_dump(exclude_none=True),
+ )
+
+ data = await self._handle_response(response, "update_webhook")
+ return WebhookResponse(**data)
+
+ async def delete_webhook(self, webhook_uuid: NonEmptyString) -> None:
+ """
+ Delete a webhook.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+
+ Args:
+ webhook_uuid: Webhook UUID to delete
+
+ Raises:
+ httpx.HTTPStatusError: If webhook not found or deletion fails
+ """
+ client = await self._get_client()
+ response = await client.delete(
+ f"{self.base_url}/webhooks/{webhook_uuid}",
+ headers=self.headers,
+ )
+
+ await self._handle_response(response, "delete_webhook")
+
+ # ============================================================================
+ # HELPER METHODS
+ # ============================================================================
+
+ async def find_webhook_by_url(self, url: NonEmptyString) -> WebhookResponse | None:
+ """
+ Find a webhook by its URL.
+
+ Args:
+ url: Webhook endpoint URL to search for
+
+ Returns:
+ Webhook if found, None otherwise
+ """
+ webhooks = await self.list_webhooks()
+ for webhook in webhooks:
+ if webhook.url == url:
+ return webhook
+ return None
+
+ async def find_webhooks_by_pattern(
+ self, pattern: NonEmptyString
+ ) -> list[WebhookResponse]:
+ """
+ Find webhooks matching a URL pattern (e.g., 'ngrok').
+
+ Args:
+ pattern: String to match in webhook URLs
+
+ Returns:
+ List of matching webhooks
+ """
+ webhooks = await self.list_webhooks()
+ return [wh for wh in webhooks if pattern in wh.url]
diff --git a/server/reflector/dailyco_api/requests.py b/server/reflector/dailyco_api/requests.py
new file mode 100644
index 00000000..e943b90f
--- /dev/null
+++ b/server/reflector/dailyco_api/requests.py
@@ -0,0 +1,158 @@
+"""
+Daily.co API Request Models
+
+Reference: https://docs.daily.co/reference/rest-api
+"""
+
+from typing import List, Literal
+
+from pydantic import BaseModel, Field
+
+from reflector.utils.string import NonEmptyString
+
+
+class RecordingsBucketConfig(BaseModel):
+ """
+ S3 bucket configuration for raw-tracks recordings.
+
+ Reference: https://docs.daily.co/reference/rest-api/rooms/create-room
+ """
+
+ bucket_name: NonEmptyString = Field(description="S3 bucket name")
+ bucket_region: NonEmptyString = Field(description="AWS region (e.g., 'us-east-1')")
+ assume_role_arn: NonEmptyString = Field(
+ description="AWS IAM role ARN that Daily.co will assume to write recordings"
+ )
+ allow_api_access: bool = Field(
+ default=True,
+ description="Whether to allow API access to recording metadata",
+ )
+
+
+class RoomProperties(BaseModel):
+ """
+ Room configuration properties.
+ """
+
+ enable_recording: Literal["cloud", "local", "raw-tracks"] | None = Field(
+ default=None,
+ description="Recording mode: 'cloud' for mixed, 'local' for local recording, 'raw-tracks' for multitrack, None to disable",
+ )
+ enable_chat: bool = Field(default=True, description="Enable in-meeting chat")
+ enable_screenshare: bool = Field(default=True, description="Enable screen sharing")
+ start_video_off: bool = Field(
+ default=False, description="Start with video off for all participants"
+ )
+ start_audio_off: bool = Field(
+ default=False, description="Start with audio muted for all participants"
+ )
+ exp: int | None = Field(
+ None, description="Room expiration timestamp (Unix epoch seconds)"
+ )
+ recordings_bucket: RecordingsBucketConfig | None = Field(
+ None, description="S3 bucket configuration for raw-tracks recordings"
+ )
+
+
+class CreateRoomRequest(BaseModel):
+ """
+ Request to create a new Daily.co room.
+
+ Reference: https://docs.daily.co/reference/rest-api/rooms/create-room
+ """
+
+ name: NonEmptyString = Field(description="Room name (must be unique within domain)")
+ privacy: Literal["public", "private"] = Field(
+ default="public", description="Room privacy setting"
+ )
+ properties: RoomProperties = Field(
+ default_factory=RoomProperties, description="Room configuration properties"
+ )
+
+
+class MeetingTokenProperties(BaseModel):
+ """
+ Properties for meeting token creation.
+
+ Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token
+ """
+
+ room_name: NonEmptyString = Field(description="Room name this token is valid for")
+ user_id: NonEmptyString | None = Field(
+ None, description="User identifier to associate with token"
+ )
+ is_owner: bool = Field(
+ default=False, description="Grant owner privileges to token holder"
+ )
+ start_cloud_recording: bool = Field(
+ default=False, description="Automatically start cloud recording on join"
+ )
+ enable_recording_ui: bool = Field(
+ default=True, description="Show recording controls in UI"
+ )
+ eject_at_token_exp: bool = Field(
+ default=False, description="Eject participant when token expires"
+ )
+ nbf: int | None = Field(
+ None, description="Not-before timestamp (Unix epoch seconds)"
+ )
+ exp: int | None = Field(
+ None, description="Expiration timestamp (Unix epoch seconds)"
+ )
+
+
+class CreateMeetingTokenRequest(BaseModel):
+ """
+ Request to create a meeting token for participant authentication.
+
+ Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token
+ """
+
+ properties: MeetingTokenProperties = Field(description="Token properties")
+
+
+class CreateWebhookRequest(BaseModel):
+ """
+ Request to create a webhook subscription.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+ """
+
+ url: NonEmptyString = Field(description="Webhook endpoint URL (must be HTTPS)")
+ eventTypes: List[
+ Literal[
+ "participant.joined",
+ "participant.left",
+ "recording.started",
+ "recording.ready-to-download",
+ "recording.error",
+ ]
+ ] = Field(
+ description="Array of event types to subscribe to (only events we handle)"
+ )
+ hmac: NonEmptyString = Field(
+ description="Base64-encoded HMAC secret for webhook signature verification"
+ )
+ basicAuth: NonEmptyString | None = Field(
+ None, description="Optional basic auth credentials for webhook endpoint"
+ )
+
+
+class UpdateWebhookRequest(BaseModel):
+ """
+ Request to update an existing webhook.
+
+ Note: Daily.co API may not support PATCH for webhooks.
+ Common pattern is to delete and recreate.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+ """
+
+ url: NonEmptyString | None = Field(None, description="New webhook endpoint URL")
+ eventTypes: List[NonEmptyString] | None = Field(
+ None, description="New array of event types"
+ )
+ hmac: NonEmptyString | None = Field(None, description="New HMAC secret")
+ basicAuth: NonEmptyString | None = Field(
+ None, description="New basic auth credentials"
+ )
diff --git a/server/reflector/dailyco_api/responses.py b/server/reflector/dailyco_api/responses.py
new file mode 100644
index 00000000..4eb84245
--- /dev/null
+++ b/server/reflector/dailyco_api/responses.py
@@ -0,0 +1,182 @@
+"""
+Daily.co API Response Models
+"""
+
+from typing import Any, Dict, List, Literal
+
+from pydantic import BaseModel, Field
+
+from reflector.utils.string import NonEmptyString
+
+# not documented in daily; we fill it according to observations
+RecordingStatus = Literal["in-progress", "finished"]
+
+
+class RoomResponse(BaseModel):
+ """
+ Response from room creation or retrieval.
+
+ Reference: https://docs.daily.co/reference/rest-api/rooms/create-room
+ """
+
+ id: NonEmptyString = Field(description="Unique room identifier (UUID)")
+ name: NonEmptyString = Field(description="Room name used in URLs")
+ api_created: bool = Field(description="Whether room was created via API")
+ privacy: Literal["public", "private"] = Field(description="Room privacy setting")
+ url: NonEmptyString = Field(description="Full room URL")
+ created_at: NonEmptyString = Field(description="ISO 8601 creation timestamp")
+ config: Dict[NonEmptyString, Any] = Field(
+ default_factory=dict, description="Room configuration properties"
+ )
+
+
+class RoomPresenceParticipant(BaseModel):
+ """
+ Participant presence information in a room.
+
+ Reference: https://docs.daily.co/reference/rest-api/rooms/get-room-presence
+ """
+
+ room: NonEmptyString = Field(description="Room name")
+ id: NonEmptyString = Field(description="Participant session ID")
+ userId: NonEmptyString | None = Field(None, description="User ID if provided")
+ userName: NonEmptyString | None = Field(None, description="User display name")
+ joinTime: NonEmptyString = Field(description="ISO 8601 join timestamp")
+ duration: int = Field(description="Duration in room (seconds)")
+
+
+class RoomPresenceResponse(BaseModel):
+ """
+ Response from room presence endpoint.
+
+ Reference: https://docs.daily.co/reference/rest-api/rooms/get-room-presence
+ """
+
+ total_count: int = Field(
+ description="Total number of participants currently in room"
+ )
+ data: List[RoomPresenceParticipant] = Field(
+ default_factory=list, description="Array of participant presence data"
+ )
+
+
+class MeetingParticipant(BaseModel):
+ """
+ Historical participant data from a meeting.
+
+ Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants
+ """
+
+ user_id: NonEmptyString = Field(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)")
+ duration: int = Field(description="Duration in meeting (seconds)")
+
+
+class MeetingParticipantsResponse(BaseModel):
+ """
+ Response from meeting participants endpoint.
+
+ Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-participants
+ """
+
+ data: List[MeetingParticipant] = Field(
+ default_factory=list, description="Array of participant data"
+ )
+
+
+class MeetingResponse(BaseModel):
+ """
+ Response from meeting information endpoint.
+
+ Reference: https://docs.daily.co/reference/rest-api/meetings/get-meeting-information
+ """
+
+ id: NonEmptyString = Field(description="Meeting session identifier (UUID)")
+ room: NonEmptyString = Field(description="Room name where meeting occurred")
+ start_time: int = Field(
+ description="Meeting start Unix timestamp (~15s granularity)"
+ )
+ duration: int = Field(description="Total meeting duration in seconds")
+ ongoing: bool = Field(description="Whether meeting is currently active")
+ max_participants: int = Field(description="Peak concurrent participant count")
+ participants: List[MeetingParticipant] = Field(
+ default_factory=list, description="Array of participant session data"
+ )
+
+
+class RecordingS3Info(BaseModel):
+ """
+ S3 bucket information for a recording.
+
+ Reference: https://docs.daily.co/reference/rest-api/recordings
+ """
+
+ bucket_name: NonEmptyString
+ bucket_region: NonEmptyString
+ endpoint: NonEmptyString | None = None
+
+
+class RecordingResponse(BaseModel):
+ """
+ Response from recording retrieval endpoint.
+
+ Reference: https://docs.daily.co/reference/rest-api/recordings
+ """
+
+ id: NonEmptyString = Field(description="Recording identifier")
+ room_name: NonEmptyString = Field(description="Room where recording occurred")
+ start_ts: int = Field(description="Recording start timestamp (Unix epoch seconds)")
+ status: RecordingStatus = Field(
+ description="Recording status ('in-progress' or 'finished')"
+ )
+ max_participants: int = Field(description="Maximum participants during recording")
+ 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")
+
+
+class MeetingTokenResponse(BaseModel):
+ """
+ Response from meeting token creation.
+
+ Reference: https://docs.daily.co/reference/rest-api/meeting-tokens/create-meeting-token
+ """
+
+ token: NonEmptyString = Field(
+ description="JWT meeting token for participant authentication"
+ )
+
+
+class WebhookResponse(BaseModel):
+ """
+ Response from webhook creation or retrieval.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+ """
+
+ uuid: NonEmptyString = Field(description="Unique webhook identifier")
+ url: NonEmptyString = Field(description="Webhook endpoint URL")
+ hmac: NonEmptyString | None = Field(
+ None, description="Base64-encoded HMAC secret for signature verification"
+ )
+ basicAuth: NonEmptyString | None = Field(
+ None, description="Basic auth credentials if configured"
+ )
+ eventTypes: List[NonEmptyString] = Field(
+ default_factory=list,
+ description="Array of event types (e.g., ['recording.started', 'participant.joined'])",
+ )
+ state: Literal["ACTIVE", "FAILED"] = Field(
+ description="Webhook state - FAILED after 3+ consecutive failures"
+ )
+ failedCount: int = Field(default=0, description="Number of consecutive failures")
+ lastMomentPushed: NonEmptyString | None = Field(
+ None, description="ISO 8601 timestamp of last successful push"
+ )
+ domainId: NonEmptyString = Field(description="Daily.co domain/account identifier")
+ createdAt: NonEmptyString = Field(description="ISO 8601 creation timestamp")
+ updatedAt: NonEmptyString = Field(description="ISO 8601 last update timestamp")
diff --git a/server/reflector/dailyco_api/webhook_utils.py b/server/reflector/dailyco_api/webhook_utils.py
new file mode 100644
index 00000000..b10d4fa2
--- /dev/null
+++ b/server/reflector/dailyco_api/webhook_utils.py
@@ -0,0 +1,229 @@
+"""
+Daily.co Webhook Utilities
+
+Utilities for verifying and parsing Daily.co webhook events.
+
+Reference: https://docs.daily.co/reference/rest-api/webhooks
+"""
+
+import base64
+import hmac
+from hashlib import sha256
+
+import structlog
+
+from .webhooks import (
+ DailyWebhookEvent,
+ ParticipantJoinedPayload,
+ ParticipantLeftPayload,
+ RecordingErrorPayload,
+ RecordingReadyToDownloadPayload,
+ RecordingStartedPayload,
+)
+
+logger = structlog.get_logger(__name__)
+
+
+def verify_webhook_signature(
+ body: bytes,
+ signature: str,
+ timestamp: str,
+ webhook_secret: str,
+) -> bool:
+ """
+ Verify Daily.co webhook signature using HMAC-SHA256.
+
+ Daily.co signature verification:
+ 1. Base64-decode the webhook secret
+ 2. Create signed content: timestamp + '.' + body
+ 3. Compute HMAC-SHA256(secret, signed_content)
+ 4. Base64-encode the result
+ 5. Compare with provided signature using constant-time comparison
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+
+ Args:
+ body: Raw request body bytes
+ signature: X-Webhook-Signature header value
+ timestamp: X-Webhook-Timestamp header value
+ webhook_secret: Base64-encoded HMAC secret
+
+ Returns:
+ True if signature is valid, False otherwise
+
+ Example:
+ >>> body = b'{"version":"1.0.0","type":"participant.joined",...}'
+ >>> signature = "abc123..."
+ >>> timestamp = "1234567890"
+ >>> secret = "your-base64-secret"
+ >>> is_valid = verify_webhook_signature(body, signature, timestamp, secret)
+ """
+ if not signature or not timestamp or not webhook_secret:
+ logger.warning(
+ "Missing required data for webhook verification",
+ has_signature=bool(signature),
+ has_timestamp=bool(timestamp),
+ has_secret=bool(webhook_secret),
+ )
+ return False
+
+ try:
+ secret_bytes = base64.b64decode(webhook_secret)
+ signed_content = timestamp.encode() + b"." + body
+ expected = hmac.new(secret_bytes, signed_content, sha256).digest()
+ expected_b64 = base64.b64encode(expected).decode()
+
+ # Constant-time comparison to prevent timing attacks
+ return hmac.compare_digest(expected_b64, signature)
+
+ except (base64.binascii.Error, ValueError, TypeError, UnicodeDecodeError) as e:
+ logger.error(
+ "Webhook signature verification failed",
+ error=str(e),
+ error_type=type(e).__name__,
+ )
+ return False
+
+
+def extract_room_name(event: DailyWebhookEvent) -> str | None:
+ """
+ Extract room name from Daily.co webhook event payload.
+
+ Args:
+ event: Parsed webhook event
+
+ Returns:
+ Room name if present and is a string, None otherwise
+
+ Example:
+ >>> event = DailyWebhookEvent(**webhook_payload)
+ >>> room_name = extract_room_name(event)
+ """
+ room = event.payload.get("room_name")
+ # Ensure we return a string, not any falsy value that might be in payload
+ return room if isinstance(room, str) else None
+
+
+def parse_participant_joined(event: DailyWebhookEvent) -> ParticipantJoinedPayload:
+ """
+ Parse participant.joined webhook event payload.
+
+ Args:
+ event: Webhook event with type "participant.joined"
+
+ Returns:
+ Parsed participant joined payload
+
+ Raises:
+ pydantic.ValidationError: If payload doesn't match expected schema
+ """
+ return ParticipantJoinedPayload(**event.payload)
+
+
+def parse_participant_left(event: DailyWebhookEvent) -> ParticipantLeftPayload:
+ """
+ Parse participant.left webhook event payload.
+
+ Args:
+ event: Webhook event with type "participant.left"
+
+ Returns:
+ Parsed participant left payload
+
+ Raises:
+ pydantic.ValidationError: If payload doesn't match expected schema
+ """
+ return ParticipantLeftPayload(**event.payload)
+
+
+def parse_recording_started(event: DailyWebhookEvent) -> RecordingStartedPayload:
+ """
+ Parse recording.started webhook event payload.
+
+ Args:
+ event: Webhook event with type "recording.started"
+
+ Returns:
+ Parsed recording started payload
+
+ Raises:
+ pydantic.ValidationError: If payload doesn't match expected schema
+ """
+ return RecordingStartedPayload(**event.payload)
+
+
+def parse_recording_ready(
+ event: DailyWebhookEvent,
+) -> RecordingReadyToDownloadPayload:
+ """
+ Parse recording.ready-to-download webhook event payload.
+
+ This event is sent when raw-tracks recordings are complete and uploaded to S3.
+ The payload includes a 'tracks' array with individual audio/video files.
+
+ Args:
+ event: Webhook event with type "recording.ready-to-download"
+
+ Returns:
+ Parsed recording ready payload with tracks array
+
+ Raises:
+ pydantic.ValidationError: If payload doesn't match expected schema
+
+ Example:
+ >>> event = DailyWebhookEvent(**webhook_payload)
+ >>> if event.type == "recording.ready-to-download":
+ ... payload = parse_recording_ready(event)
+ ... audio_tracks = [t for t in payload.tracks if t.type == "audio"]
+ """
+ return RecordingReadyToDownloadPayload(**event.payload)
+
+
+def parse_recording_error(event: DailyWebhookEvent) -> RecordingErrorPayload:
+ """
+ Parse recording.error webhook event payload.
+
+ Args:
+ event: Webhook event with type "recording.error"
+
+ Returns:
+ Parsed recording error payload
+
+ Raises:
+ pydantic.ValidationError: If payload doesn't match expected schema
+ """
+ return RecordingErrorPayload(**event.payload)
+
+
+# Webhook event type to parser mapping
+WEBHOOK_PARSERS = {
+ "participant.joined": parse_participant_joined,
+ "participant.left": parse_participant_left,
+ "recording.started": parse_recording_started,
+ "recording.ready-to-download": parse_recording_ready,
+ "recording.error": parse_recording_error,
+}
+
+
+def parse_webhook_payload(event: DailyWebhookEvent):
+ """
+ Parse webhook event payload based on event type.
+
+ Args:
+ event: Webhook event
+
+ Returns:
+ Typed payload model based on event type, or raw dict if unknown
+
+ Example:
+ >>> event = DailyWebhookEvent(**webhook_payload)
+ >>> payload = parse_webhook_payload(event)
+ >>> if isinstance(payload, ParticipantJoinedPayload):
+ ... print(f"User {payload.user_name} joined")
+ """
+ parser = WEBHOOK_PARSERS.get(event.type)
+ if parser:
+ return parser(event)
+ else:
+ logger.warning("Unknown webhook event type", event_type=event.type)
+ return event.payload
diff --git a/server/reflector/dailyco_api/webhooks.py b/server/reflector/dailyco_api/webhooks.py
new file mode 100644
index 00000000..862f4996
--- /dev/null
+++ b/server/reflector/dailyco_api/webhooks.py
@@ -0,0 +1,199 @@
+"""
+Daily.co Webhook Event Models
+
+Reference: https://docs.daily.co/reference/rest-api/webhooks
+"""
+
+from typing import Any, Dict, Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+from reflector.utils.string import NonEmptyString
+
+
+def normalize_timestamp_to_int(v):
+ """
+ Normalize float timestamps to int by truncating decimal part.
+
+ Daily.co sometimes sends timestamps as floats (e.g., 1708972279.96).
+ Pydantic expects int for fields typed as `int`.
+ """
+ if v is None:
+ return v
+ if isinstance(v, float):
+ return int(v)
+ return v
+
+
+WebhookEventType = Literal[
+ "participant.joined",
+ "participant.left",
+ "recording.started",
+ "recording.ready-to-download",
+ "recording.error",
+]
+
+
+class DailyTrack(BaseModel):
+ """
+ Individual audio or video track from a multitrack recording.
+
+ Reference: https://docs.daily.co/reference/rest-api/recordings
+ """
+
+ type: Literal["audio", "video"]
+ s3Key: NonEmptyString = Field(description="S3 object key for the track file")
+ size: int = Field(description="File size in bytes")
+
+
+class DailyWebhookEvent(BaseModel):
+ """
+ Base structure for all Daily.co webhook events.
+ All events share five common fields documented below.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks
+ """
+
+ version: NonEmptyString = Field(
+ description="Represents the version of the event. This uses semantic versioning to inform a consumer if the payload has introduced any breaking changes"
+ )
+ type: WebhookEventType = Field(
+ description="Represents the type of the event described in the payload"
+ )
+ id: NonEmptyString = Field(
+ description="An identifier representing this specific event"
+ )
+ payload: Dict[NonEmptyString, Any] = Field(
+ description="An object representing the event, whose fields are described in the corresponding payload class"
+ )
+ event_ts: int = Field(
+ description="Documenting when the webhook itself was sent. This timestamp is different than the time of the event the webhook describes. For example, a recording.started event will contain a start_ts timestamp of when the actual recording started, and a slightly later event_ts timestamp indicating when the webhook event was sent"
+ )
+
+ _normalize_event_ts = field_validator("event_ts", mode="before")(
+ normalize_timestamp_to_int
+ )
+
+
+class ParticipantJoinedPayload(BaseModel):
+ """
+ Payload for participant.joined webhook event.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks/events/participant-joined
+ """
+
+ room_name: NonEmptyString | None = Field(None, description="Daily.co room name")
+ session_id: NonEmptyString = Field(description="Daily.co session identifier")
+ user_id: NonEmptyString = Field(description="User identifier (may be encoded)")
+ user_name: NonEmptyString | None = Field(None, description="User display name")
+ joined_at: int = Field(description="Join timestamp in Unix epoch seconds")
+
+ _normalize_joined_at = field_validator("joined_at", mode="before")(
+ normalize_timestamp_to_int
+ )
+
+
+class ParticipantLeftPayload(BaseModel):
+ """
+ Payload for participant.left webhook event.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks/events/participant-left
+ """
+
+ room_name: NonEmptyString | None = Field(None, description="Daily.co room name")
+ session_id: NonEmptyString = Field(description="Daily.co session identifier")
+ user_id: NonEmptyString = Field(description="User identifier (may be encoded)")
+ user_name: NonEmptyString | None = Field(None, description="User display name")
+ joined_at: int = Field(description="Join timestamp in Unix epoch seconds")
+ duration: int | None = Field(
+ None, description="Duration of participation in seconds"
+ )
+
+ _normalize_joined_at = field_validator("joined_at", mode="before")(
+ normalize_timestamp_to_int
+ )
+
+
+class RecordingStartedPayload(BaseModel):
+ """
+ Payload for recording.started webhook event.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks/events/recording-started
+ """
+
+ room_name: NonEmptyString | None = Field(None, description="Daily.co room name")
+ recording_id: NonEmptyString = Field(description="Recording identifier")
+ start_ts: int | None = Field(None, description="Recording start timestamp")
+
+ _normalize_start_ts = field_validator("start_ts", mode="before")(
+ normalize_timestamp_to_int
+ )
+
+
+class RecordingReadyToDownloadPayload(BaseModel):
+ """
+ Payload for recording.ready-to-download webhook event.
+ This is sent when raw-tracks recordings are complete and uploaded to S3.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks/events/recording-ready-to-download
+ """
+
+ type: Literal["cloud", "raw-tracks"] = Field(
+ description="The type of recording that was generated"
+ )
+ recording_id: NonEmptyString = Field(
+ description="An ID identifying the recording that was generated"
+ )
+ room_name: NonEmptyString = Field(
+ description="The name of the room where the recording was made"
+ )
+ start_ts: int = Field(
+ description="The Unix epoch time in seconds representing when the recording started"
+ )
+ status: Literal["finished"] = Field(
+ description="The status of the given recording (always 'finished' in ready-to-download webhook, see RecordingStatus in responses.py for full API statuses)"
+ )
+ max_participants: int = Field(
+ description="The number of participants on the call that were recorded"
+ )
+ duration: int = Field(description="The duration in seconds of the call")
+ s3_key: NonEmptyString = Field(
+ description="The location of the recording in the provided S3 bucket"
+ )
+ share_token: NonEmptyString | None = Field(
+ None, description="undocumented documented secret field"
+ )
+ tracks: list[DailyTrack] | None = Field(
+ None,
+ description="If the recording is a raw-tracks recording, a tracks field will be provided. If role permissions have been removed, the tracks field may be null",
+ )
+
+ _normalize_start_ts = field_validator("start_ts", mode="before")(
+ normalize_timestamp_to_int
+ )
+
+
+class RecordingErrorPayload(BaseModel):
+ """
+ Payload for recording.error webhook event.
+
+ Reference: https://docs.daily.co/reference/rest-api/webhooks/events/recording-error
+ """
+
+ action: Literal["clourd-recording-err", "cloud-recording-error"] = Field(
+ description="A string describing the event that was emitted (both variants are documented)"
+ )
+ error_msg: NonEmptyString = Field(description="The error message returned")
+ instance_id: NonEmptyString = Field(
+ description="The recording instance ID that was passed into the start recording command"
+ )
+ room_name: NonEmptyString = Field(
+ description="The name of the room where the recording was made"
+ )
+ timestamp: int = Field(
+ description="The Unix epoch time in seconds representing when the error was emitted"
+ )
+
+ _normalize_timestamp = field_validator("timestamp", mode="before")(
+ normalize_timestamp_to_int
+ )
diff --git a/server/reflector/video_platforms/daily.py b/server/reflector/video_platforms/daily.py
index 7bec4864..7485cc95 100644
--- a/server/reflector/video_platforms/daily.py
+++ b/server/reflector/video_platforms/daily.py
@@ -1,12 +1,17 @@
-import base64
-import hmac
from datetime import datetime
-from hashlib import sha256
-from http import HTTPStatus
-from typing import Any, Dict, Optional
-
-import httpx
+from reflector.dailyco_api import (
+ CreateMeetingTokenRequest,
+ CreateRoomRequest,
+ DailyApiClient,
+ MeetingParticipantsResponse,
+ MeetingTokenProperties,
+ RecordingResponse,
+ RecordingsBucketConfig,
+ RoomPresenceResponse,
+ RoomProperties,
+ verify_webhook_signature,
+)
from reflector.db.daily_participant_sessions import (
daily_participant_sessions_controller,
)
@@ -23,18 +28,17 @@ from .models import MeetingData, RecordingType, SessionData, VideoPlatformConfig
class DailyClient(VideoPlatformClient):
PLATFORM_NAME: Platform = "daily"
- TIMEOUT = 10
- BASE_URL = "https://api.daily.co/v1"
TIMESTAMP_FORMAT = "%Y%m%d%H%M%S"
RECORDING_NONE: RecordingType = "none"
RECORDING_CLOUD: RecordingType = "cloud"
def __init__(self, config: VideoPlatformConfig):
super().__init__(config)
- self.headers = {
- "Authorization": f"Bearer {config.api_key}",
- "Content-Type": "application/json",
- }
+ self._api_client = DailyApiClient(
+ api_key=config.api_key,
+ webhook_secret=config.webhook_secret,
+ timeout=10.0,
+ )
async def create_meeting(
self, room_name_prefix: NonEmptyString, end_date: datetime, room: Room
@@ -49,57 +53,43 @@ class DailyClient(VideoPlatformClient):
timestamp = datetime.now().strftime(self.TIMESTAMP_FORMAT)
room_name = f"{room_name_prefix}{ROOM_PREFIX_SEPARATOR}{timestamp}"
- data = {
- "name": room_name,
- "privacy": "private" if room.is_locked else "public",
- "properties": {
- "enable_recording": "raw-tracks"
- if room.recording_type != self.RECORDING_NONE
- else False,
- "enable_chat": True,
- "enable_screenshare": True,
- "start_video_off": False,
- "start_audio_off": False,
- "exp": int(end_date.timestamp()),
- },
- }
+ properties = RoomProperties(
+ enable_recording="raw-tracks"
+ if room.recording_type != self.RECORDING_NONE
+ else False,
+ enable_chat=True,
+ enable_screenshare=True,
+ 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:
daily_storage = get_dailyco_storage()
assert daily_storage.bucket_name, "S3 bucket must be configured"
- data["properties"]["recordings_bucket"] = {
- "bucket_name": daily_storage.bucket_name,
- "bucket_region": daily_storage.region,
- "assume_role_arn": daily_storage.role_credential,
- "allow_api_access": True,
- }
- async with httpx.AsyncClient() as client:
- response = await client.post(
- f"{self.BASE_URL}/rooms",
- headers=self.headers,
- json=data,
- timeout=self.TIMEOUT,
+ properties.recordings_bucket = RecordingsBucketConfig(
+ bucket_name=daily_storage.bucket_name,
+ bucket_region=daily_storage.region,
+ assume_role_arn=daily_storage.role_credential,
+ allow_api_access=True,
)
- if response.status_code >= 400:
- logger.error(
- "Daily.co API error",
- status_code=response.status_code,
- response_body=response.text,
- request_data=data,
- )
- response.raise_for_status()
- result = response.json()
- room_url = result["url"]
+ request = CreateRoomRequest(
+ name=room_name,
+ privacy="private" if room.is_locked else "public",
+ properties=properties,
+ )
+
+ result = await self._api_client.create_room(request)
return MeetingData(
- meeting_id=result["id"],
- room_name=result["name"],
- room_url=room_url,
- host_room_url=room_url,
+ meeting_id=result.id,
+ room_name=result.name,
+ room_url=result.url,
+ host_room_url=result.url,
platform=self.PLATFORM_NAME,
- extra_data=result,
+ extra_data=result.model_dump(),
)
async def get_room_sessions(self, room_name: str) -> list[SessionData]:
@@ -108,7 +98,7 @@ class DailyClient(VideoPlatformClient):
Daily.co doesn't provide historical session API, so we query our database
where participant.joined/left webhooks are stored.
"""
- from reflector.db.meetings import meetings_controller
+ from reflector.db.meetings import meetings_controller # noqa: PLC0415
meeting = await meetings_controller.get_by_room_name(room_name)
if not meeting:
@@ -127,135 +117,65 @@ class DailyClient(VideoPlatformClient):
for s in sessions
]
- async def get_room_presence(self, room_name: str) -> Dict[str, Any]:
- """Get room presence/session data for a Daily.co room.
+ async def get_room_presence(self, room_name: str) -> RoomPresenceResponse:
+ """Get room presence/session data for a Daily.co room."""
+ return await self._api_client.get_room_presence(room_name)
- Example response:
- {
- "total_count": 1,
- "data": [
- {
- "room": "w2pp2cf4kltgFACPKXmX",
- "id": "d61cd7b2-a273-42b4-89bd-be763fd562c1",
- "userId": "pbZ+ismP7dk=",
- "userName": "Moishe",
- "joinTime": "2023-01-01T20:53:19.000Z",
- "duration": 2312
- }
- ]
- }
- """
- async with httpx.AsyncClient() as client:
- response = await client.get(
- f"{self.BASE_URL}/rooms/{room_name}/presence",
- headers=self.headers,
- timeout=self.TIMEOUT,
- )
- response.raise_for_status()
- return response.json()
+ async def get_meeting_participants(
+ self, meeting_id: str
+ ) -> MeetingParticipantsResponse:
+ """Get participant data for a specific Daily.co meeting."""
+ return await self._api_client.get_meeting_participants(meeting_id)
- async def get_meeting_participants(self, meeting_id: str) -> Dict[str, Any]:
- """Get participant data for a specific Daily.co meeting.
-
- Example response:
- {
- "data": [
- {
- "user_id": "4q47OTmqa/w=",
- "participant_id": "d61cd7b2-a273-42b4-89bd-be763fd562c1",
- "user_name": "Lindsey",
- "join_time": 1672786813,
- "duration": 150
- },
- {
- "user_id": "pbZ+ismP7dk=",
- "participant_id": "b3d56359-14d7-46af-ac8b-18f8c991f5f6",
- "user_name": "Moishe",
- "join_time": 1672786797,
- "duration": 165
- }
- ]
- }
- """
- async with httpx.AsyncClient() as client:
- response = await client.get(
- f"{self.BASE_URL}/meetings/{meeting_id}/participants",
- headers=self.headers,
- timeout=self.TIMEOUT,
- )
- response.raise_for_status()
- return response.json()
-
- async def get_recording(self, recording_id: str) -> Dict[str, Any]:
- async with httpx.AsyncClient() as client:
- response = await client.get(
- f"{self.BASE_URL}/recordings/{recording_id}",
- headers=self.headers,
- timeout=self.TIMEOUT,
- )
- response.raise_for_status()
- return response.json()
+ 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:
- async with httpx.AsyncClient() as client:
- response = await client.delete(
- f"{self.BASE_URL}/rooms/{room_name}",
- headers=self.headers,
- timeout=self.TIMEOUT,
- )
- return response.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
+ """Delete a room (idempotent - succeeds even if room doesn't exist)."""
+ await self._api_client.delete_room(room_name)
+ return True
async def upload_logo(self, room_name: str, logo_path: str) -> bool:
return True
def verify_webhook_signature(
- self, body: bytes, signature: str, timestamp: Optional[str] = None
+ self, body: bytes, signature: str, timestamp: str | None = None
) -> bool:
- """Verify Daily.co webhook signature.
-
- Daily.co uses:
- - X-Webhook-Signature header
- - X-Webhook-Timestamp header
- - Signature format: HMAC-SHA256(base64_decode(secret), timestamp + '.' + body)
- - Result is base64 encoded
- """
- if not signature or not timestamp:
+ """Verify Daily.co webhook signature using dailyco_api module."""
+ if not self.config.webhook_secret:
+ logger.warning("Webhook secret not configured")
return False
- try:
- secret_bytes = base64.b64decode(self.config.webhook_secret)
-
- signed_content = timestamp.encode() + b"." + body
-
- expected = hmac.new(secret_bytes, signed_content, sha256).digest()
- expected_b64 = base64.b64encode(expected).decode()
-
- return hmac.compare_digest(expected_b64, signature)
- except Exception as e:
- logger.error("Daily.co webhook signature verification failed", exc_info=e)
- return False
+ return verify_webhook_signature(
+ body=body,
+ signature=signature,
+ timestamp=timestamp or "",
+ webhook_secret=self.config.webhook_secret,
+ )
async def create_meeting_token(
self,
room_name: DailyRoomName,
enable_recording: bool,
- user_id: Optional[str] = None,
+ user_id: str | None = None,
) -> str:
- data = {"properties": {"room_name": room_name}}
+ properties = MeetingTokenProperties(
+ room_name=room_name,
+ user_id=user_id,
+ start_cloud_recording=enable_recording,
+ enable_recording_ui=not enable_recording,
+ )
- if enable_recording:
- data["properties"]["start_cloud_recording"] = True
- data["properties"]["enable_recording_ui"] = False
+ request = CreateMeetingTokenRequest(properties=properties)
+ result = await self._api_client.create_meeting_token(request)
+ return result.token
- if user_id:
- data["properties"]["user_id"] = user_id
+ async def close(self):
+ """Clean up API client resources."""
+ await self._api_client.close()
- async with httpx.AsyncClient() as client:
- response = await client.post(
- f"{self.BASE_URL}/meeting-tokens",
- headers=self.headers,
- json=data,
- timeout=self.TIMEOUT,
- )
- response.raise_for_status()
- return response.json()["token"]
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
+ await self.close()
diff --git a/server/reflector/views/daily.py b/server/reflector/views/daily.py
index baad97e9..733c70a3 100644
--- a/server/reflector/views/daily.py
+++ b/server/reflector/views/daily.py
@@ -1,10 +1,14 @@
import json
from datetime import datetime, timezone
-from typing import Any, Dict, Literal
from fastapi import APIRouter, HTTPException, Request
-from pydantic import BaseModel
+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,
@@ -13,7 +17,6 @@ from reflector.db.daily_participant_sessions import (
from reflector.db.meetings import meetings_controller
from reflector.logger import logger as _logger
from reflector.settings import settings
-from reflector.utils.daily import DailyRoomName
from reflector.video_platforms.factory import create_platform_client
from reflector.worker.process import process_multitrack_recording
@@ -22,30 +25,6 @@ router = APIRouter()
logger = _logger.bind(platform="daily")
-class DailyTrack(BaseModel):
- type: Literal["audio", "video"]
- s3Key: str
- size: int
-
-
-class DailyWebhookEvent(BaseModel):
- version: str
- type: str
- id: str
- payload: Dict[str, Any]
- event_ts: float
-
-
-def _extract_room_name(event: DailyWebhookEvent) -> DailyRoomName | None:
- """Extract room name from Daily event payload.
-
- Daily.co API inconsistency:
- - participant.* events use "room" field
- - recording.* events use "room_name" field
- """
- return event.payload.get("room_name") or event.payload.get("room")
-
-
@router.post("/webhook")
async def webhook(request: Request):
"""Handle Daily webhook events.
@@ -77,18 +56,14 @@ async def webhook(request: Request):
client = create_platform_client("daily")
- # TEMPORARY: Bypass signature check for testing
- # TODO: Remove this after testing is complete
- BYPASS_FOR_TESTING = True
- if not BYPASS_FOR_TESTING:
- if not client.verify_webhook_signature(body, signature, timestamp):
- logger.warning(
- "Invalid webhook signature",
- signature=signature,
- timestamp=timestamp,
- has_body=bool(body),
- )
- raise HTTPException(status_code=401, detail="Invalid webhook signature")
+ if not client.verify_webhook_signature(body, signature, timestamp):
+ logger.warning(
+ "Invalid webhook signature",
+ signature=signature,
+ timestamp=timestamp,
+ has_body=bool(body),
+ )
+ raise HTTPException(status_code=401, detail="Invalid webhook signature")
try:
body_json = json.loads(body)
@@ -99,14 +74,12 @@ async def webhook(request: Request):
logger.info("Received Daily webhook test event")
return {"status": "ok"}
- # Parse as actual event
try:
event = DailyWebhookEvent(**body_json)
except Exception as e:
logger.error("Failed to parse webhook event", error=str(e), body=body.decode())
raise HTTPException(status_code=422, detail="Invalid event format")
- # Handle participant events
if event.type == "participant.joined":
await _handle_participant_joined(event)
elif event.type == "participant.left":
@@ -154,7 +127,7 @@ async def webhook(request: Request):
async def _handle_participant_joined(event: DailyWebhookEvent):
- daily_room_name = _extract_room_name(event)
+ daily_room_name = extract_room_name(event)
if not daily_room_name:
logger.warning("participant.joined: no room in payload", payload=event.payload)
return
@@ -167,7 +140,6 @@ async def _handle_participant_joined(event: DailyWebhookEvent):
return
payload = event.payload
- logger.warning({"payload": payload})
joined_at = datetime.fromtimestamp(payload["joined_at"], tz=timezone.utc)
session_id = f"{meeting.id}:{payload['session_id']}"
@@ -225,7 +197,7 @@ async def _handle_participant_joined(event: DailyWebhookEvent):
async def _handle_participant_left(event: DailyWebhookEvent):
- room_name = _extract_room_name(event)
+ room_name = extract_room_name(event)
if not room_name:
logger.warning("participant.left: no room in payload", payload=event.payload)
return
@@ -268,7 +240,7 @@ async def _handle_participant_left(event: DailyWebhookEvent):
async def _handle_recording_started(event: DailyWebhookEvent):
- room_name = _extract_room_name(event)
+ room_name = extract_room_name(event)
if not room_name:
logger.warning(
"recording.started: no room_name in payload", payload=event.payload
@@ -301,7 +273,7 @@ async def _handle_recording_ready(event: DailyWebhookEvent):
]
}
"""
- room_name = _extract_room_name(event)
+ room_name = extract_room_name(event)
recording_id = event.payload.get("recording_id")
tracks_raw = event.payload.get("tracks", [])
@@ -350,8 +322,8 @@ async def _handle_recording_ready(event: DailyWebhookEvent):
async def _handle_recording_error(event: DailyWebhookEvent):
- room_name = _extract_room_name(event)
- error = event.payload.get("error", "Unknown error")
+ payload = parse_recording_error(event)
+ room_name = payload.room_name
if room_name:
meeting = await meetings_controller.get_by_room_name(room_name)
@@ -360,6 +332,6 @@ async def _handle_recording_error(event: DailyWebhookEvent):
"Recording error",
meeting_id=meeting.id,
room_name=room_name,
- error=error,
+ error=payload.error_msg,
platform="daily",
)
diff --git a/server/scripts/list_daily_webhooks.py b/server/scripts/list_daily_webhooks.py
index c3c13568..e2e3c912 100755
--- a/server/scripts/list_daily_webhooks.py
+++ b/server/scripts/list_daily_webhooks.py
@@ -6,53 +6,19 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
-import httpx
-
+from reflector.dailyco_api import DailyApiClient
from reflector.settings import settings
async def list_webhooks():
- """
- List all Daily.co webhooks for this account.
- """
+ """List all Daily.co webhooks for this account using dailyco_api module."""
if not settings.DAILY_API_KEY:
print("Error: DAILY_API_KEY not set")
return 1
- headers = {
- "Authorization": f"Bearer {settings.DAILY_API_KEY}",
- "Content-Type": "application/json",
- }
-
- async with httpx.AsyncClient() as client:
+ async with DailyApiClient(api_key=settings.DAILY_API_KEY) as client:
try:
- """
- Daily.co webhook list response format:
- [
- {
- "uuid": "0b4e4c7c-5eaf-46fe-990b-a3752f5684f5",
- "url": "{{webhook_url}}",
- "hmac": "NQrSA5z0FkJ44QPrFerW7uCc5kdNLv3l2FDEKDanL1U=",
- "basicAuth": null,
- "eventTypes": [
- "recording.started",
- "recording.ready-to-download"
- ],
- "state": "ACTVIE",
- "failedCount": 0,
- "lastMomentPushed": "2023-08-15T18:29:52.000Z",
- "domainId": "{{domain_id}}",
- "createdAt": "2023-08-15T18:28:30.000Z",
- "updatedAt": "2023-08-15T18:29:52.000Z"
- }
- ]
- """
- resp = await client.get(
- "https://api.daily.co/v1/webhooks",
- headers=headers,
- )
- resp.raise_for_status()
- webhooks = resp.json()
+ webhooks = await client.list_webhooks()
if not webhooks:
print("No webhooks found")
@@ -62,12 +28,12 @@ async def list_webhooks():
for webhook in webhooks:
print("=" * 80)
- print(f"UUID: {webhook['uuid']}")
- print(f"URL: {webhook['url']}")
- print(f"State: {webhook['state']}")
- print(f"Event Types: {', '.join(webhook.get('eventTypes', []))}")
+ print(f"UUID: {webhook.uuid}")
+ print(f"URL: {webhook.url}")
+ print(f"State: {webhook.state}")
+ print(f"Event Types: {', '.join(webhook.eventTypes)}")
print(
- f"HMAC Secret: {'✓ Configured' if webhook.get('hmac') else '✗ Not set'}"
+ f"HMAC Secret: {'✓ Configured' if webhook.hmac else '✗ Not set'}"
)
print()
@@ -78,12 +44,8 @@ async def list_webhooks():
return 0
- except httpx.HTTPStatusError as e:
- print(f"Error fetching webhooks: {e}")
- print(f"Response: {e.response.text}")
- return 1
except Exception as e:
- print(f"Unexpected error: {e}")
+ print(f"Error fetching webhooks: {e}")
return 1
diff --git a/server/scripts/recreate_daily_webhook.py b/server/scripts/recreate_daily_webhook.py
index a378baf2..e4ac9ce9 100644
--- a/server/scripts/recreate_daily_webhook.py
+++ b/server/scripts/recreate_daily_webhook.py
@@ -6,56 +6,60 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
-import httpx
-
+from reflector.dailyco_api import (
+ CreateWebhookRequest,
+ DailyApiClient,
+)
from reflector.settings import settings
async def setup_webhook(webhook_url: str):
"""
- Create or update Daily.co webhook for this environment.
+ Create or update Daily.co webhook for this environment using dailyco_api module.
Uses DAILY_WEBHOOK_UUID to identify existing webhook.
"""
if not settings.DAILY_API_KEY:
print("Error: DAILY_API_KEY not set")
return 1
- headers = {
- "Authorization": f"Bearer {settings.DAILY_API_KEY}",
- "Content-Type": "application/json",
- }
+ if not settings.DAILY_WEBHOOK_SECRET:
+ print("Error: DAILY_WEBHOOK_SECRET not set")
+ return 1
- webhook_data = {
- "url": webhook_url,
- "eventTypes": [
- "participant.joined",
- "participant.left",
- "recording.started",
- "recording.ready-to-download",
- "recording.error",
- ],
- "hmac": settings.DAILY_WEBHOOK_SECRET,
- }
+ event_types = [
+ "participant.joined",
+ "participant.left",
+ "recording.started",
+ "recording.ready-to-download",
+ "recording.error",
+ ]
- async with httpx.AsyncClient() as client:
+ async with DailyApiClient(api_key=settings.DAILY_API_KEY) as client:
webhook_uuid = settings.DAILY_WEBHOOK_UUID
if webhook_uuid:
- # Update existing webhook
print(f"Updating existing webhook {webhook_uuid}...")
try:
- resp = await client.patch(
- f"https://api.daily.co/v1/webhooks/{webhook_uuid}",
- headers=headers,
- json=webhook_data,
+ # Note: Daily.co doesn't support PATCH well, so we delete + recreate
+ await client.delete_webhook(webhook_uuid)
+ print(f"Deleted old webhook {webhook_uuid}")
+
+ request = CreateWebhookRequest(
+ url=webhook_url,
+ eventTypes=event_types,
+ hmac=settings.DAILY_WEBHOOK_SECRET,
)
- resp.raise_for_status()
- result = resp.json()
- print(f"✓ Updated webhook {result['uuid']} (state: {result['state']})")
- print(f" URL: {result['url']}")
- return 0
- except httpx.HTTPStatusError as e:
- if e.response.status_code == 404:
+ result = await client.create_webhook(request)
+
+ print(
+ f"✓ Created replacement webhook {result.uuid} (state: {result.state})"
+ )
+ print(f" URL: {result.url}")
+
+ webhook_uuid = result.uuid
+
+ except Exception as e:
+ if hasattr(e, "response") and e.response.status_code == 404:
print(f"Webhook {webhook_uuid} not found, creating new one...")
webhook_uuid = None # Fall through to creation
else:
@@ -63,17 +67,17 @@ async def setup_webhook(webhook_url: str):
return 1
if not webhook_uuid:
- # Create new webhook
print("Creating new webhook...")
- resp = await client.post(
- "https://api.daily.co/v1/webhooks", headers=headers, json=webhook_data
+ request = CreateWebhookRequest(
+ url=webhook_url,
+ eventTypes=event_types,
+ hmac=settings.DAILY_WEBHOOK_SECRET,
)
- resp.raise_for_status()
- result = resp.json()
- webhook_uuid = result["uuid"]
+ result = await client.create_webhook(request)
+ webhook_uuid = result.uuid
- print(f"✓ Created webhook {webhook_uuid} (state: {result['state']})")
- print(f" URL: {result['url']}")
+ print(f"✓ Created webhook {webhook_uuid} (state: {result.state})")
+ print(f" URL: {result.url}")
print()
print("=" * 60)
print("IMPORTANT: Add this to your environment variables:")
@@ -114,7 +118,7 @@ if __name__ == "__main__":
)
print()
print("Behavior:")
- print(" - If DAILY_WEBHOOK_UUID set: Updates existing webhook")
+ print(" - If DAILY_WEBHOOK_UUID set: Deletes old webhook, creates new one")
print(
" - If DAILY_WEBHOOK_UUID empty: Creates new webhook, saves UUID to .env"
)
From 11731c9d38439b04e93b1c3afbd7090bad11a11f Mon Sep 17 00:00:00 2001
From: Igor Monadical
Date: Mon, 24 Nov 2025 10:35:06 -0500
Subject: [PATCH 77/77] feat: multitrack cli (#735)
* multitrack cli prd
* prd/todo (no-mistakes)
* multitrack cli (no-mistakes)
* multitrack cli (no-mistakes)
* multitrack cli (no-mistakes)
* multitrack cli (no-mistakes)
* remove multitrack tests most worthless
* useless comments away
* useless comments away
---------
Co-authored-by: Igor Loskutov
---
server/reflector/tools/cli_multitrack.py | 347 +++++++++++++++++++++++
server/reflector/tools/process.py | 179 ++++++++++--
server/tests/test_s3_url_parser.py | 136 +++++++++
3 files changed, 643 insertions(+), 19 deletions(-)
create mode 100644 server/reflector/tools/cli_multitrack.py
create mode 100644 server/tests/test_s3_url_parser.py
diff --git a/server/reflector/tools/cli_multitrack.py b/server/reflector/tools/cli_multitrack.py
new file mode 100644
index 00000000..aad5ab2f
--- /dev/null
+++ b/server/reflector/tools/cli_multitrack.py
@@ -0,0 +1,347 @@
+import asyncio
+import sys
+import time
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional, Protocol
+
+import structlog
+from celery.result import AsyncResult
+
+from reflector.db import get_database
+from reflector.db.transcripts import SourceKind, Transcript, transcripts_controller
+from reflector.pipelines.main_multitrack_pipeline import (
+ task_pipeline_multitrack_process,
+)
+from reflector.storage import get_transcripts_storage
+from reflector.tools.process import (
+ extract_result_from_entry,
+ parse_s3_url,
+ validate_s3_objects,
+)
+
+logger = structlog.get_logger(__name__)
+
+DEFAULT_PROCESSING_TIMEOUT_SECONDS = 3600
+
+MAX_ERROR_MESSAGE_LENGTH = 500
+
+TASK_POLL_INTERVAL_SECONDS = 2
+
+
+class StatusCallback(Protocol):
+ def __call__(self, state: str, elapsed_seconds: int) -> None: ...
+
+
+@dataclass
+class MultitrackTaskResult:
+ success: bool
+ transcript_id: str
+ error: Optional[str] = None
+
+
+async def create_multitrack_transcript(
+ bucket_name: str,
+ track_keys: List[str],
+ source_language: str,
+ target_language: str,
+ user_id: Optional[str] = None,
+) -> Transcript:
+ num_tracks = len(track_keys)
+ track_word = "track" if num_tracks == 1 else "tracks"
+ transcript_name = f"Multitrack ({num_tracks} {track_word})"
+
+ transcript = await transcripts_controller.add(
+ transcript_name,
+ source_kind=SourceKind.FILE,
+ source_language=source_language,
+ target_language=target_language,
+ user_id=user_id,
+ )
+
+ logger.info(
+ "Created multitrack transcript",
+ transcript_id=transcript.id,
+ name=transcript_name,
+ bucket=bucket_name,
+ num_tracks=len(track_keys),
+ )
+
+ return transcript
+
+
+def submit_multitrack_task(
+ transcript_id: str, bucket_name: str, track_keys: List[str]
+) -> AsyncResult:
+ result = task_pipeline_multitrack_process.delay(
+ transcript_id=transcript_id,
+ bucket_name=bucket_name,
+ track_keys=track_keys,
+ )
+
+ logger.info(
+ "Multitrack task submitted",
+ transcript_id=transcript_id,
+ task_id=result.id,
+ bucket=bucket_name,
+ num_tracks=len(track_keys),
+ )
+
+ return result
+
+
+async def wait_for_task(
+ result: AsyncResult,
+ transcript_id: str,
+ timeout_seconds: int = DEFAULT_PROCESSING_TIMEOUT_SECONDS,
+ poll_interval: int = TASK_POLL_INTERVAL_SECONDS,
+ status_callback: Optional[StatusCallback] = None,
+) -> MultitrackTaskResult:
+ start_time = time.time()
+ last_status = None
+
+ while not result.ready():
+ elapsed = time.time() - start_time
+ if elapsed > timeout_seconds:
+ error_msg = (
+ f"Task {result.id} did not complete within {timeout_seconds}s "
+ f"for transcript {transcript_id}"
+ )
+ logger.error(
+ "Task timeout",
+ task_id=result.id,
+ transcript_id=transcript_id,
+ elapsed_seconds=elapsed,
+ )
+ raise TimeoutError(error_msg)
+
+ if result.state != last_status:
+ if status_callback:
+ status_callback(result.state, int(elapsed))
+ last_status = result.state
+
+ await asyncio.sleep(poll_interval)
+
+ if result.failed():
+ error_info = result.info
+ traceback_info = getattr(result, "traceback", None)
+
+ logger.error(
+ "Multitrack task failed",
+ transcript_id=transcript_id,
+ task_id=result.id,
+ error=str(error_info),
+ has_traceback=bool(traceback_info),
+ )
+
+ error_detail = str(error_info)
+ if traceback_info:
+ error_detail += f"\nTraceback:\n{traceback_info}"
+
+ return MultitrackTaskResult(
+ success=False, transcript_id=transcript_id, error=error_detail
+ )
+
+ logger.info(
+ "Multitrack task completed",
+ transcript_id=transcript_id,
+ task_id=result.id,
+ state=result.state,
+ )
+
+ return MultitrackTaskResult(success=True, transcript_id=transcript_id)
+
+
+async def update_transcript_status(
+ transcript_id: str,
+ status: str,
+ error: Optional[str] = None,
+ max_error_length: int = MAX_ERROR_MESSAGE_LENGTH,
+) -> None:
+ database = get_database()
+ connected = False
+
+ try:
+ await database.connect()
+ connected = True
+
+ transcript = await transcripts_controller.get_by_id(transcript_id)
+ if transcript:
+ update_data: Dict[str, Any] = {"status": status}
+
+ if error:
+ if len(error) > max_error_length:
+ error = error[: max_error_length - 3] + "..."
+ update_data["error"] = error
+
+ await transcripts_controller.update(transcript, update_data)
+
+ logger.info(
+ "Updated transcript status",
+ transcript_id=transcript_id,
+ status=status,
+ has_error=bool(error),
+ )
+ except Exception as e:
+ logger.warning(
+ "Failed to update transcript status",
+ transcript_id=transcript_id,
+ error=str(e),
+ )
+ finally:
+ if connected:
+ try:
+ await database.disconnect()
+ except Exception as e:
+ logger.warning(f"Database disconnect failed: {e}")
+
+
+async def process_multitrack(
+ bucket_name: str,
+ track_keys: List[str],
+ source_language: str,
+ target_language: str,
+ user_id: Optional[str] = None,
+ timeout_seconds: int = DEFAULT_PROCESSING_TIMEOUT_SECONDS,
+ status_callback: Optional[StatusCallback] = None,
+) -> MultitrackTaskResult:
+ """High-level orchestration for multitrack processing."""
+ database = get_database()
+ transcript = None
+ connected = False
+
+ try:
+ await database.connect()
+ connected = True
+
+ transcript = await create_multitrack_transcript(
+ bucket_name=bucket_name,
+ track_keys=track_keys,
+ source_language=source_language,
+ target_language=target_language,
+ user_id=user_id,
+ )
+
+ result = submit_multitrack_task(
+ transcript_id=transcript.id, bucket_name=bucket_name, track_keys=track_keys
+ )
+
+ except Exception as e:
+ if transcript:
+ try:
+ await update_transcript_status(
+ transcript_id=transcript.id, status="failed", error=str(e)
+ )
+ except Exception as update_error:
+ logger.error(
+ "Failed to update transcript status after error",
+ original_error=str(e),
+ update_error=str(update_error),
+ transcript_id=transcript.id,
+ )
+ raise
+ finally:
+ if connected:
+ try:
+ await database.disconnect()
+ except Exception as e:
+ logger.warning(f"Database disconnect failed: {e}")
+
+ # Poll outside database connection
+ task_result = await wait_for_task(
+ result=result,
+ transcript_id=transcript.id,
+ timeout_seconds=timeout_seconds,
+ poll_interval=2,
+ status_callback=status_callback,
+ )
+
+ if not task_result.success:
+ await update_transcript_status(
+ transcript_id=transcript.id, status="failed", error=task_result.error
+ )
+
+ return task_result
+
+
+def print_progress(message: str) -> None:
+ """Print progress message to stderr for CLI visibility."""
+ print(f"{message}", file=sys.stderr)
+
+
+def create_status_callback() -> StatusCallback:
+ """Create callback for task status updates during polling."""
+
+ def callback(state: str, elapsed_seconds: int) -> None:
+ print_progress(
+ f"Multitrack pipeline status: {state} (elapsed: {elapsed_seconds}s)"
+ )
+
+ return callback
+
+
+async def process_multitrack_cli(
+ s3_urls: List[str],
+ source_language: str,
+ target_language: str,
+ output_path: Optional[str] = None,
+) -> None:
+ if not s3_urls:
+ raise ValueError("At least one track required for multitrack processing")
+
+ bucket_keys = []
+ for url in s3_urls:
+ try:
+ bucket, key = parse_s3_url(url)
+ bucket_keys.append((bucket, key))
+ except ValueError as e:
+ raise ValueError(f"Invalid S3 URL '{url}': {e}") from e
+
+ buckets = set(bucket for bucket, _ in bucket_keys)
+ if len(buckets) > 1:
+ raise ValueError(
+ f"All tracks must be in the same S3 bucket. "
+ f"Found {len(buckets)} different buckets: {sorted(buckets)}. "
+ f"Please upload all files to a single bucket."
+ )
+
+ primary_bucket = bucket_keys[0][0]
+ track_keys = [key for _, key in bucket_keys]
+
+ print_progress(
+ f"Starting multitrack CLI processing: "
+ f"bucket={primary_bucket}, num_tracks={len(track_keys)}, "
+ f"source_language={source_language}, target_language={target_language}"
+ )
+
+ storage = get_transcripts_storage()
+ await validate_s3_objects(storage, bucket_keys)
+ print_progress(f"S3 validation complete: {len(bucket_keys)} objects verified")
+
+ result = await process_multitrack(
+ bucket_name=primary_bucket,
+ track_keys=track_keys,
+ source_language=source_language,
+ target_language=target_language,
+ user_id=None,
+ timeout_seconds=3600,
+ status_callback=create_status_callback(),
+ )
+
+ if not result.success:
+ error_msg = (
+ f"Multitrack pipeline failed for transcript {result.transcript_id}\n"
+ )
+ if result.error:
+ error_msg += f"Error: {result.error}\n"
+ raise RuntimeError(error_msg)
+
+ print_progress(
+ f"Multitrack processing complete for transcript {result.transcript_id}"
+ )
+
+ database = get_database()
+ await database.connect()
+ try:
+ await extract_result_from_entry(result.transcript_id, output_path)
+ finally:
+ await database.disconnect()
diff --git a/server/reflector/tools/process.py b/server/reflector/tools/process.py
index eb770f76..a3a74138 100644
--- a/server/reflector/tools/process.py
+++ b/server/reflector/tools/process.py
@@ -9,7 +9,10 @@ import shutil
import sys
import time
from pathlib import Path
-from typing import Any, Dict, List, Literal
+from typing import Any, Dict, List, Literal, Tuple
+from urllib.parse import unquote, urlparse
+
+from botocore.exceptions import BotoCoreError, ClientError, NoCredentialsError
from reflector.db.transcripts import SourceKind, TranscriptTopic, transcripts_controller
from reflector.logger import logger
@@ -20,10 +23,119 @@ from reflector.pipelines.main_live_pipeline import pipeline_post as live_pipelin
from reflector.pipelines.main_live_pipeline import (
pipeline_process as live_pipeline_process,
)
+from reflector.storage import Storage
+
+
+def validate_s3_bucket_name(bucket: str) -> None:
+ if not bucket:
+ raise ValueError("Bucket name cannot be empty")
+ if len(bucket) > 255: # Absolute max for any region
+ raise ValueError(f"Bucket name too long: {len(bucket)} characters (max 255)")
+
+
+def validate_s3_key(key: str) -> None:
+ if not key:
+ raise ValueError("S3 key cannot be empty")
+ if len(key) > 1024:
+ raise ValueError(f"S3 key too long: {len(key)} characters (max 1024)")
+
+
+def parse_s3_url(url: str) -> Tuple[str, str]:
+ parsed = urlparse(url)
+
+ if parsed.scheme == "s3":
+ bucket = parsed.netloc
+ key = parsed.path.lstrip("/")
+ if parsed.fragment:
+ logger.debug(
+ "URL fragment ignored (not part of S3 key)",
+ url=url,
+ fragment=parsed.fragment,
+ )
+ if not bucket or not key:
+ raise ValueError(f"Invalid S3 URL: {url} (missing bucket or key)")
+ bucket = unquote(bucket)
+ key = unquote(key)
+ validate_s3_bucket_name(bucket)
+ validate_s3_key(key)
+ return bucket, key
+
+ elif parsed.scheme in ("http", "https"):
+ if ".s3." in parsed.netloc or parsed.netloc.endswith(".s3.amazonaws.com"):
+ bucket = parsed.netloc.split(".")[0]
+ key = parsed.path.lstrip("/")
+ if parsed.fragment:
+ logger.debug("URL fragment ignored", url=url, fragment=parsed.fragment)
+ if not bucket or not key:
+ raise ValueError(f"Invalid S3 URL: {url} (missing bucket or key)")
+ bucket = unquote(bucket)
+ key = unquote(key)
+ validate_s3_bucket_name(bucket)
+ validate_s3_key(key)
+ return bucket, key
+
+ elif parsed.netloc.startswith("s3.") and "amazonaws.com" in parsed.netloc:
+ path_parts = parsed.path.lstrip("/").split("/", 1)
+ if len(path_parts) != 2:
+ raise ValueError(f"Invalid S3 URL: {url} (missing bucket or key)")
+ bucket, key = path_parts
+ if parsed.fragment:
+ logger.debug("URL fragment ignored", url=url, fragment=parsed.fragment)
+ bucket = unquote(bucket)
+ key = unquote(key)
+ validate_s3_bucket_name(bucket)
+ validate_s3_key(key)
+ return bucket, key
+
+ else:
+ raise ValueError(f"Invalid S3 URL format: {url} (not recognized as S3 URL)")
+
+ else:
+ raise ValueError(f"Invalid S3 URL scheme: {url} (must be s3:// or https://)")
+
+
+async def validate_s3_objects(
+ storage: Storage, bucket_keys: List[Tuple[str, str]]
+) -> None:
+ async with storage.session.client("s3") as client:
+
+ async def check_object(bucket: str, key: str) -> None:
+ try:
+ await client.head_object(Bucket=bucket, Key=key)
+ except ClientError as e:
+ error_code = e.response["Error"]["Code"]
+ if error_code in ("404", "NoSuchKey"):
+ raise ValueError(f"S3 object not found: s3://{bucket}/{key}") from e
+ elif error_code in ("403", "Forbidden", "AccessDenied"):
+ raise ValueError(
+ f"Access denied for S3 object: s3://{bucket}/{key}. "
+ f"Check AWS credentials and permissions"
+ ) from e
+ else:
+ raise ValueError(
+ f"S3 error {error_code} for s3://{bucket}/{key}: "
+ f"{e.response['Error'].get('Message', 'Unknown error')}"
+ ) from e
+ except NoCredentialsError as e:
+ raise ValueError(
+ "AWS credentials not configured. Set AWS_ACCESS_KEY_ID and "
+ "AWS_SECRET_ACCESS_KEY environment variables"
+ ) from e
+ except BotoCoreError as e:
+ raise ValueError(
+ f"AWS service error for s3://{bucket}/{key}: {str(e)}"
+ ) from e
+ except Exception as e:
+ raise ValueError(
+ f"Unexpected error validating s3://{bucket}/{key}: {str(e)}"
+ ) from e
+
+ await asyncio.gather(
+ *(check_object(bucket, key) for bucket, key in bucket_keys)
+ )
def serialize_topics(topics: List[TranscriptTopic]) -> List[Dict[str, Any]]:
- """Convert TranscriptTopic objects to JSON-serializable dicts"""
serialized = []
for topic in topics:
topic_dict = topic.model_dump()
@@ -32,7 +144,6 @@ def serialize_topics(topics: List[TranscriptTopic]) -> List[Dict[str, Any]]:
def debug_print_speakers(serialized_topics: List[Dict[str, Any]]) -> None:
- """Print debug info about speakers found in topics"""
all_speakers = set()
for topic_dict in serialized_topics:
for word in topic_dict.get("words", []):
@@ -47,8 +158,6 @@ def debug_print_speakers(serialized_topics: List[Dict[str, Any]]) -> None:
TranscriptId = str
-# common interface for every flow: it needs an Entry in db with specific ceremony (file path + status + actual file in file system)
-# ideally we want to get rid of it at some point
async def prepare_entry(
source_path: str,
source_language: str,
@@ -65,9 +174,7 @@ async def prepare_entry(
user_id=None,
)
- logger.info(
- f"Created empty transcript {transcript.id} for file {file_path.name} because technically we need an empty transcript before we start transcript"
- )
+ logger.info(f"Created transcript {transcript.id} for {file_path.name}")
# pipelines expect files as upload.*
@@ -83,7 +190,6 @@ async def prepare_entry(
return transcript.id
-# same reason as prepare_entry
async def extract_result_from_entry(
transcript_id: TranscriptId, output_path: str
) -> None:
@@ -193,13 +299,20 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Process audio files with speaker diarization"
)
- parser.add_argument("source", help="Source file (mp3, wav, mp4...)")
+ parser.add_argument(
+ "source",
+ help="Source file (mp3, wav, mp4...) or comma-separated S3 URLs with --multitrack",
+ )
parser.add_argument(
"--pipeline",
- required=True,
choices=["live", "file"],
help="Pipeline type to use for processing (live: streaming/incremental, file: batch/parallel)",
)
+ parser.add_argument(
+ "--multitrack",
+ action="store_true",
+ help="Process multiple audio tracks from comma-separated S3 URLs",
+ )
parser.add_argument(
"--source-language", default="en", help="Source language code (default: en)"
)
@@ -209,12 +322,40 @@ if __name__ == "__main__":
parser.add_argument("--output", "-o", help="Output file (output.jsonl)")
args = parser.parse_args()
- asyncio.run(
- process(
- args.source,
- args.source_language,
- args.target_language,
- args.pipeline,
- args.output,
+ if args.multitrack:
+ if not args.source:
+ parser.error("Source URLs required for multitrack processing")
+
+ s3_urls = [url.strip() for url in args.source.split(",") if url.strip()]
+
+ if not s3_urls:
+ parser.error("At least one S3 URL required for multitrack processing")
+
+ from reflector.tools.cli_multitrack import process_multitrack_cli
+
+ asyncio.run(
+ process_multitrack_cli(
+ s3_urls,
+ args.source_language,
+ args.target_language,
+ args.output,
+ )
+ )
+ else:
+ if not args.pipeline:
+ parser.error("--pipeline is required for single-track processing")
+
+ if "," in args.source:
+ parser.error(
+ "Multiple files detected. Use --multitrack flag for multitrack processing"
+ )
+
+ asyncio.run(
+ process(
+ args.source,
+ args.source_language,
+ args.target_language,
+ args.pipeline,
+ args.output,
+ )
)
- )
diff --git a/server/tests/test_s3_url_parser.py b/server/tests/test_s3_url_parser.py
new file mode 100644
index 00000000..638f7c29
--- /dev/null
+++ b/server/tests/test_s3_url_parser.py
@@ -0,0 +1,136 @@
+"""Tests for S3 URL parsing functionality in reflector.tools.process"""
+
+import pytest
+
+from reflector.tools.process import parse_s3_url
+
+
+class TestParseS3URL:
+ """Test cases for parse_s3_url function"""
+
+ def test_parse_s3_protocol(self):
+ """Test parsing s3:// protocol URLs"""
+ bucket, key = parse_s3_url("s3://my-bucket/path/to/file.webm")
+ assert bucket == "my-bucket"
+ assert key == "path/to/file.webm"
+
+ def test_parse_s3_protocol_deep_path(self):
+ """Test s3:// with deeply nested paths"""
+ bucket, key = parse_s3_url("s3://bucket-name/very/deep/path/to/audio.mp4")
+ assert bucket == "bucket-name"
+ assert key == "very/deep/path/to/audio.mp4"
+
+ def test_parse_https_subdomain_format(self):
+ """Test parsing https://bucket.s3.amazonaws.com/key format"""
+ bucket, key = parse_s3_url("https://my-bucket.s3.amazonaws.com/path/file.webm")
+ assert bucket == "my-bucket"
+ assert key == "path/file.webm"
+
+ def test_parse_https_regional_subdomain(self):
+ """Test parsing regional endpoint with subdomain"""
+ bucket, key = parse_s3_url(
+ "https://my-bucket.s3.us-west-2.amazonaws.com/path/file.webm"
+ )
+ assert bucket == "my-bucket"
+ assert key == "path/file.webm"
+
+ def test_parse_https_path_style(self):
+ """Test parsing https://s3.amazonaws.com/bucket/key format"""
+ bucket, key = parse_s3_url("https://s3.amazonaws.com/my-bucket/path/file.webm")
+ assert bucket == "my-bucket"
+ assert key == "path/file.webm"
+
+ def test_parse_https_regional_path_style(self):
+ """Test parsing regional endpoint with path style"""
+ bucket, key = parse_s3_url(
+ "https://s3.us-east-1.amazonaws.com/my-bucket/path/file.webm"
+ )
+ assert bucket == "my-bucket"
+ assert key == "path/file.webm"
+
+ def test_parse_url_encoded_keys(self):
+ """Test parsing URL-encoded keys"""
+ bucket, key = parse_s3_url(
+ "s3://my-bucket/path%20with%20spaces/file%2Bname.webm"
+ )
+ assert bucket == "my-bucket"
+ assert key == "path with spaces/file+name.webm" # Should be decoded
+
+ def test_parse_url_encoded_https(self):
+ """Test URL-encoded keys with HTTPS format"""
+ bucket, key = parse_s3_url(
+ "https://my-bucket.s3.amazonaws.com/file%20with%20spaces.webm"
+ )
+ assert bucket == "my-bucket"
+ assert key == "file with spaces.webm"
+
+ def test_invalid_url_no_scheme(self):
+ """Test that URLs without scheme raise ValueError"""
+ with pytest.raises(ValueError, match="Invalid S3 URL scheme"):
+ parse_s3_url("my-bucket/path/file.webm")
+
+ def test_invalid_url_wrong_scheme(self):
+ """Test that non-S3 schemes raise ValueError"""
+ with pytest.raises(ValueError, match="Invalid S3 URL scheme"):
+ parse_s3_url("ftp://my-bucket/path/file.webm")
+
+ def test_invalid_s3_missing_bucket(self):
+ """Test s3:// URL without bucket raises ValueError"""
+ with pytest.raises(ValueError, match="missing bucket or key"):
+ parse_s3_url("s3:///path/file.webm")
+
+ def test_invalid_s3_missing_key(self):
+ """Test s3:// URL without key raises ValueError"""
+ with pytest.raises(ValueError, match="missing bucket or key"):
+ parse_s3_url("s3://my-bucket/")
+
+ def test_invalid_s3_empty_key(self):
+ """Test s3:// URL with empty key raises ValueError"""
+ with pytest.raises(ValueError, match="missing bucket or key"):
+ parse_s3_url("s3://my-bucket")
+
+ def test_invalid_https_not_s3(self):
+ """Test HTTPS URL that's not S3 raises ValueError"""
+ with pytest.raises(ValueError, match="not recognized as S3 URL"):
+ parse_s3_url("https://example.com/path/file.webm")
+
+ def test_invalid_https_subdomain_missing_key(self):
+ """Test HTTPS subdomain format without key raises ValueError"""
+ with pytest.raises(ValueError, match="missing bucket or key"):
+ parse_s3_url("https://my-bucket.s3.amazonaws.com/")
+
+ def test_invalid_https_path_style_missing_parts(self):
+ """Test HTTPS path style with missing bucket/key raises ValueError"""
+ with pytest.raises(ValueError, match="missing bucket or key"):
+ parse_s3_url("https://s3.amazonaws.com/")
+
+ def test_bucket_with_dots(self):
+ """Test parsing bucket names with dots"""
+ bucket, key = parse_s3_url("s3://my.bucket.name/path/file.webm")
+ assert bucket == "my.bucket.name"
+ assert key == "path/file.webm"
+
+ def test_bucket_with_hyphens(self):
+ """Test parsing bucket names with hyphens"""
+ bucket, key = parse_s3_url("s3://my-bucket-name-123/path/file.webm")
+ assert bucket == "my-bucket-name-123"
+ assert key == "path/file.webm"
+
+ def test_key_with_special_chars(self):
+ """Test keys with various special characters"""
+ # Note: # is treated as URL fragment separator, not part of key
+ bucket, key = parse_s3_url("s3://bucket/2024-01-01_12:00:00/file.webm")
+ assert bucket == "bucket"
+ assert key == "2024-01-01_12:00:00/file.webm"
+
+ def test_fragment_handling(self):
+ """Test that URL fragments are properly ignored"""
+ bucket, key = parse_s3_url("s3://bucket/path/to/file.webm#fragment123")
+ assert bucket == "bucket"
+ assert key == "path/to/file.webm" # Fragment not included
+
+ def test_http_scheme_s3_url(self):
+ """Test that HTTP (not HTTPS) S3 URLs are supported"""
+ bucket, key = parse_s3_url("http://my-bucket.s3.amazonaws.com/path/file.webm")
+ assert bucket == "my-bucket"
+ assert key == "path/file.webm"