mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-31 20:58:03 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd6ca77d9e | |||
| 0a0cdae896 | |||
| c7e3168ba3 | |||
| f1d3799434 | |||
| e59aaaa998 | |||
| 76a24d4787 | |||
| 943b81676b | |||
| 7369379106 | |||
| c02d54b6ff | |||
| cec2eb5a10 | |||
| 2858d141f4 | |||
| f10429581b | |||
| b3369cddf7 | |||
| cfffadc780 | |||
| 4ae31b3297 | |||
| 954991c7c3 | |||
| 6e2a7f0ca6 | |||
| 24404e4ea2 | |||
| f18d784615 | |||
| 1bfe6b8977 | |||
| 84845709d0 | |||
| e000377b5c | |||
| a9fa3eb4ba | |||
| 2ec3f54532 | |||
| d723c14d84 | |||
| e5f0b1c202 | |||
| 9e0d57a8c6 | |||
| 7ba0803b71 | |||
| beacfc3d3f | |||
| 52e5d99e83 | |||
| 15ca2b41b4 | |||
| e34f3dd219 | |||
| feb573551f | |||
| 1d0a0cc637 | |||
| eae93f771f | |||
| 45175a2a54 | |||
| 8b22059b18 |
@@ -8,17 +8,66 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(agent) support Voxtral realtime as inference engine
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔥(backend) remove the S3 storage-event webhook for recordings
|
||||
- ♻️(backend) always finalize recordings using the LiveKit egress_ended webhook
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.409.5 to 1.414.0
|
||||
|
||||
## [1.29.0] - 2026-08-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(any) let any authenticated user manage the lobby on trusted rooms
|
||||
|
||||
### Changed
|
||||
|
||||
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||
- 📱(frontend) stack idle modal buttons in a column on mobile
|
||||
- 📱(frontend) improve feedback screen responsiveness on mobile
|
||||
- ⬆️(frontend) upgrade @fontsource-variable/atkinson-hyperlegible-next
|
||||
- ⬆️(frontend) upgrade i18next-resources-to-backend from 1.2.1 to 1.2.3
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.1 to 5.101.4
|
||||
- ⬆️(frontend) upgrade @pandacss/preset-panda from 1.11.3 to 1.12.0
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.404.1 to 1.409.5
|
||||
- ⚡️(frontend) apply frugal constraint to the active meeting audio track
|
||||
- ⚡️(backend) replace blocking Redis KEYS with cursor-based SCAN
|
||||
- ✨(summary) add hostname to analytics properties
|
||||
|
||||
## [1.28.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- 📈(frontend) track errors when starting or stopping a recording
|
||||
- 🚸(frontend) explain camera-in-use failures on the join screen
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(backend) accept form-urlencoded on the user token endpoint
|
||||
- ✨(summary) configurable s3 region
|
||||
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
|
||||
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
|
||||
- 💄(frontend) increase the blur intensity
|
||||
|
||||
### Fixed
|
||||
|
||||
- 📝(docs) fix minor typos in comments and docstrings
|
||||
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
|
||||
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
|
||||
- 🐛(frontend) treat client-initiated connect aborts as events
|
||||
- 🐛(frontend) use state instead of a ref for MoreControls container
|
||||
- 🐛(frontend) stop init_virtual_background from firing on blur updates
|
||||
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
|
||||
- 🐛(frontend) fix joined notification tile no longer rendering properly
|
||||
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
|
||||
- 🐛(frontend) handle Firefox/Windows AbortError on device start
|
||||
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
|
||||
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
|
||||
|
||||
## [1.27.0] - 2026-08-14
|
||||
|
||||
|
||||
+17
@@ -16,6 +16,23 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Removing S3 storage-event webhooks for recordings
|
||||
|
||||
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
|
||||
|
||||
**Required for every deployment:** LiveKit must be able to deliver webhooks to the backend at `/api/v1.0/rooms/webhooks-livekit/`. This is now the only way a recording reaches a saved state; if `egress_ended` is never delivered, recordings stay in the `active` state.
|
||||
|
||||
For hosters who had configured storage-event webhooks:
|
||||
- Recordings reach the same final state, but they are now finalized when LiveKit reports the egress as ended rather than when the storage backend reports the upload.
|
||||
- Remove the event notification from your bucket configuration: it now targets a non-existent endpoint and will fail on every delivery.
|
||||
|
||||
For hosters who had **not** configured storage-event webhooks:
|
||||
- Nothing changes. Recordings have been finalized from the `egress_ended` webhook since v1.22.0.
|
||||
|
||||
In both cases, the following settings are no longer used and can be removed from your env: `RECORDING_EVENT_PARSER_CLASS`, `RECORDING_ENABLE_STORAGE_EVENT_AUTH`, `RECORDING_STORAGE_EVENT_ENABLE`, `RECORDING_STORAGE_EVENT_TOKEN`.
|
||||
|
||||
On completion of the egress, a recording moves to `notification_succeeded`, or to `saved` if notifying external services failed.
|
||||
|
||||
## v1.23.0
|
||||
|
||||
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||
|
||||
+2
-17
@@ -46,21 +46,6 @@ services:
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
createwebhook:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password &&
|
||||
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
||||
/usr/bin/mc admin service restart meet --wait --json &&
|
||||
sleep 15 &&
|
||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -86,7 +71,6 @@ services:
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
- createwebhook
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
networks:
|
||||
@@ -180,7 +164,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:18
|
||||
image: node:22
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
@@ -267,6 +251,7 @@ services:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
command: ["python", "multi_user_transcriber.py", "dev"]
|
||||
env_file:
|
||||
- env.d/development/multi_user_transcriber
|
||||
volumes:
|
||||
|
||||
@@ -23,14 +23,11 @@ It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit
|
||||
To use the room recording feature, the following components are required:
|
||||
|
||||
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
|
||||
- A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
|
||||
- A S3-compatible object storage where the egress uploads the recorded files.
|
||||
- An email service to notify room owners when a recording is available for download.
|
||||
- Webhook events configured between LiveKit Server and the backend.
|
||||
|
||||
|
||||
> [!CAUTION]
|
||||
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
|
||||
|
||||
> [!NOTE]
|
||||
> Celery isn’t in use for these async tasks yet. It’s something we’d like to add, but it’s not planned at this stage.
|
||||
|
||||
@@ -75,7 +72,7 @@ sequenceDiagram
|
||||
LiveKit->>Egress: Stop recording
|
||||
Egress->>Storage: Upload recorded file
|
||||
|
||||
Storage->>Backend: Storage event notification
|
||||
LiveKit->>Backend: POST /api/v1.0/rooms/webhooks-livekit/ (egress_ended)
|
||||
Backend->>Backend: Update Recording status to SAVED
|
||||
Backend->>Email: Send notification to room owner
|
||||
|
||||
@@ -94,10 +91,6 @@ sequenceDiagram
|
||||
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
||||
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
|
||||
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
|
||||
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. |
|
||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
| **RECORDING_ENCODING_ENABLED** | Boolean | `False` | When `False`, LiveKit Egress uses its built-in `H264_720P_30` preset. When `True`, the `RECORDING_ENCODING_*` values below are sent to LiveKit as advanced `EncodingOptions`. See [Tuning recording encoding](#tuning-recording-encoding). |
|
||||
@@ -109,19 +102,6 @@ sequenceDiagram
|
||||
| **RECORDING_ENCODING_KEY_FRAME_INTERVAL_S** | Float | `4.0` | Keyframe interval in seconds. Drives seek granularity in the recorded MP4 (a player can only seek to keyframe boundaries). Larger values give the encoder slightly more bits for non-keyframe content at a fixed bitrate. `4.0` is a standard VOD value. Only applied when `RECORDING_ENCODING_ENABLED` is `True`. |
|
||||
|
||||
|
||||
### Manual Storage Webhook
|
||||
|
||||
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
|
||||
|
||||
1. Configure your S3 bucket to send file creation events to the backend webhook.
|
||||
2. Enable events and token in settings:
|
||||
|
||||
```python
|
||||
RECORDING_STORAGE_EVENT_ENABLE = True
|
||||
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
|
||||
RECORDING_STORAGE_EVENT_TOKEN = <token>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
|
||||
@@ -406,10 +406,6 @@ These are the environmental options available on meet backend.
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
|
||||
@@ -63,8 +63,6 @@ ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
RECORDING_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_ENABLE=False
|
||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
STT_PROVIDER=kyutai # kyutai, deepgram
|
||||
STT_PROVIDER=voxtral-vllm # voxtral-vllm, kyutai, deepgram
|
||||
ENABLE_SILERO_VAD=False
|
||||
|
||||
DEEPGRAM_API_KEY=
|
||||
DEEPGRAM_API_KEY=your-deepgram-api-key
|
||||
|
||||
KYUTAI_STT_BASE_URL=
|
||||
KYUTAI_API_KEY=
|
||||
KYUTAI_STT_BASE_URL=url
|
||||
KYUTAI_API_KEY=your-kyutai-api-key
|
||||
|
||||
VOXTRAL_VLLM_BASE_URL=wss://<host>/v1/realtime
|
||||
VOXTRAL_VLLM_MODEL=voxtral-mini-4b-realtime-2602
|
||||
VOXTRAL_VLLM_API_KEY=your-vllm-api-key
|
||||
VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS=480
|
||||
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Multi user transcription agent."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
|
||||
@@ -25,6 +26,7 @@ from livekit.agents import (
|
||||
)
|
||||
from livekit.plugins import deepgram, silero
|
||||
|
||||
import voxtral_vllm_stt
|
||||
from observability import configure_sentry, set_job_context
|
||||
from tasks import done_callback
|
||||
|
||||
@@ -36,9 +38,18 @@ TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcr
|
||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||
ENABLE_SILERO_VAD = os.getenv("ENABLE_SILERO_VAD", "true").lower() == "true"
|
||||
|
||||
SESSION_DRAIN_TIMEOUT_S = 15.0
|
||||
|
||||
def create_stt_provider():
|
||||
"""Create STT provider based on environment configuration."""
|
||||
|
||||
def create_stt_provider(vad: silero.VAD | None = None):
|
||||
"""Create STT provider based on environment configuration.
|
||||
|
||||
Args:
|
||||
vad: Shared, prewarmed VAD instance. Required in practice for
|
||||
voxtral-vllm (no server-side endpointing): if omitted, the plugin
|
||||
loads its own Silero model synchronously on the event loop, once
|
||||
per participant, freezing all active sessions for the duration.
|
||||
"""
|
||||
if STT_PROVIDER == "deepgram":
|
||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||
# detect_language is NOT supported for real-time streaming
|
||||
@@ -49,6 +60,9 @@ def create_stt_provider():
|
||||
)
|
||||
elif STT_PROVIDER == "kyutai":
|
||||
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
|
||||
elif STT_PROVIDER == "voxtral-vllm":
|
||||
# The plugin resolves base_url / model / api_key from the environment.
|
||||
_stt_instance = voxtral_vllm_stt.STT(vad=vad)
|
||||
else:
|
||||
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
||||
|
||||
@@ -58,9 +72,9 @@ def create_stt_provider():
|
||||
class Transcriber(Agent):
|
||||
"""Create a transcription agent for a specific participant."""
|
||||
|
||||
def __init__(self, *, participant_identity: str):
|
||||
def __init__(self, *, participant_identity: str, vad: silero.VAD | None = None):
|
||||
"""Init transcription agent."""
|
||||
stt = create_stt_provider()
|
||||
stt = create_stt_provider(vad=vad)
|
||||
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
@@ -76,6 +90,7 @@ class MultiUserTranscriber:
|
||||
"""Init multi user transcription agent."""
|
||||
self.ctx = ctx
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self._starting: dict[str, asyncio.Task] = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
def start(self):
|
||||
@@ -96,22 +111,30 @@ class MultiUserTranscriber:
|
||||
|
||||
def on_participant_connected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle new participant connection by starting transcription session."""
|
||||
if participant.identity in self._sessions:
|
||||
identity = participant.identity
|
||||
if identity in self._sessions or identity in self._starting:
|
||||
return
|
||||
|
||||
logger.info(f"starting session for {participant.identity}")
|
||||
logger.info(f"starting session for {identity}")
|
||||
task = asyncio.create_task(self._start_session(participant))
|
||||
self._starting[identity] = task
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(lambda t, i=identity: self._starting.pop(i, None))
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"start transcription session for {participant.identity}",
|
||||
f"start transcription session for {identity}",
|
||||
)
|
||||
)
|
||||
|
||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle participant disconnection by closing transcription session."""
|
||||
if (start_task := self._starting.pop(participant.identity, None)) is not None:
|
||||
logger.info(f"cancelling pending session start for {participant.identity}")
|
||||
start_task.cancel()
|
||||
return
|
||||
|
||||
if (session := self._sessions.pop(participant.identity, None)) is None:
|
||||
return
|
||||
|
||||
@@ -127,10 +150,12 @@ class MultiUserTranscriber:
|
||||
)
|
||||
|
||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||
"""Create and start transcription session for participant."""
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
"""Create and start transcription session for participant.
|
||||
|
||||
Deduplication happens synchronously in on_participant_connected via
|
||||
self._starting; by the time this coroutine runs, the identity is
|
||||
already reserved.
|
||||
"""
|
||||
vad = self.ctx.proc.userdata.get("vad", None)
|
||||
session = AgentSession(vad=vad)
|
||||
room_io = RoomIO(
|
||||
@@ -141,18 +166,30 @@ class MultiUserTranscriber:
|
||||
text_input=False, audio_output=False, text_output=True
|
||||
),
|
||||
)
|
||||
await room_io.start()
|
||||
await session.start(
|
||||
agent=Transcriber(
|
||||
participant_identity=participant.identity,
|
||||
try:
|
||||
await room_io.start()
|
||||
await session.start(
|
||||
agent=Transcriber(
|
||||
participant_identity=participant.identity,
|
||||
vad=vad,
|
||||
)
|
||||
)
|
||||
)
|
||||
except BaseException:
|
||||
with contextlib.suppress(Exception):
|
||||
await session.aclose()
|
||||
raise
|
||||
self._sessions[participant.identity] = session
|
||||
return session
|
||||
|
||||
async def _close_session(self, sess: AgentSession) -> None:
|
||||
"""Close and cleanup transcription session."""
|
||||
await sess.drain()
|
||||
try:
|
||||
await asyncio.wait_for(sess.drain(), timeout=SESSION_DRAIN_TIMEOUT_S)
|
||||
except (TimeoutError, asyncio.TimeoutError):
|
||||
logger.warning(
|
||||
"session drain timed out after %.0fs; forcing close",
|
||||
SESSION_DRAIN_TIMEOUT_S,
|
||||
)
|
||||
await sess.aclose()
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.27.0"
|
||||
version = "1.29.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
@@ -12,6 +12,8 @@ dependencies = [
|
||||
"protobuf==6.33.6",
|
||||
"minio==7.2.20",
|
||||
"sentry-sdk==2.66.1",
|
||||
"websockets==17.1",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
Generated
+771
-511
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,476 @@
|
||||
"""LiveKit STT plugin for Voxtral Realtime served via vLLM (/v1/realtime).
|
||||
|
||||
vLLM exposes Voxtral Realtime over a WebSocket that follows the OpenAI Realtime
|
||||
API protocol (not Mistral's proprietary realtime protocol).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import weakref
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import websockets
|
||||
from livekit.agents import (
|
||||
DEFAULT_API_CONNECT_OPTIONS,
|
||||
APIConnectionError,
|
||||
APIConnectOptions,
|
||||
APIStatusError,
|
||||
stt,
|
||||
utils,
|
||||
)
|
||||
from livekit.agents import (
|
||||
vad as vad_module,
|
||||
)
|
||||
from livekit.agents.types import NOT_GIVEN, NotGivenOr
|
||||
from livekit.agents.utils import is_given
|
||||
|
||||
logger = logging.getLogger("voxtral-vllm-stt")
|
||||
|
||||
SAMPLE_RATE = 16000
|
||||
NUM_CHANNELS = 1
|
||||
CHUNK_SAMPLES = 1600 # 100 ms @ 16 kHz mono
|
||||
PREROLL_CHUNKS = 5 # keep 500 ms of audio before start of speech as detected by VAD
|
||||
|
||||
# Reconnect policy: exponential backoff capped at MAX, give up after MAX_ATTEMPTS
|
||||
# consecutive failures (a successful handshake resets the counter).
|
||||
RECONNECT_BACKOFF_BASE_S = 0.5
|
||||
RECONNECT_BACKOFF_MAX_S = 8.0
|
||||
RECONNECT_MAX_ATTEMPTS = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
class _STTOptions:
|
||||
base_url: str
|
||||
model: str
|
||||
api_key: str | None
|
||||
target_streaming_delay_ms: int | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PendingUtterance:
|
||||
"""An utterance in flight on the shared websocket used for reconnect.
|
||||
|
||||
`sent_chunks` holds every chunk we have already enqueued for send on this
|
||||
or a prior connection; on reconnect we replay them before resuming reads
|
||||
from `queue`. vLLM concatenates `input_audio_buffer.append` events into a
|
||||
single audio buffer per generation, so duplicates from a partial prior send
|
||||
are harmless.
|
||||
"""
|
||||
|
||||
queue: asyncio.Queue[bytes | None]
|
||||
sent_chunks: list[bytes] = field(default_factory=list)
|
||||
ended: bool = False
|
||||
|
||||
|
||||
class STT(stt.STT):
|
||||
"""LiveKit STT speaking the OpenAI Realtime protocol served by vLLM."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: NotGivenOr[str] = NOT_GIVEN,
|
||||
model: NotGivenOr[str] = NOT_GIVEN,
|
||||
api_key: NotGivenOr[str] = NOT_GIVEN,
|
||||
target_streaming_delay_ms: NotGivenOr[int] = NOT_GIVEN,
|
||||
vad: vad_module.VAD | None = None,
|
||||
) -> None:
|
||||
"""Build the STT.
|
||||
|
||||
Args:
|
||||
base_url: WebSocket URL of the vLLM realtime endpoint, e.g.
|
||||
ws://example:8000/v1/realtime. Falls back to $VOXTRAL_VLLM_BASE_URL.
|
||||
model: Model name exposed by vLLM, default
|
||||
mistralai/Voxtral-Mini-4B-Realtime-2602.
|
||||
api_key: Optional bearer token. Falls back to $VOXTRAL_VLLM_API_KEY.
|
||||
target_streaming_delay_ms: Target streaming delay in ms forwarded to
|
||||
vLLM via session.update. Falls back to
|
||||
$VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS, else server default.
|
||||
vad: Voice Activity Detector. If omitted, Silero VAD is loaded.
|
||||
"""
|
||||
super().__init__(
|
||||
capabilities=stt.STTCapabilities(streaming=True, interim_results=True)
|
||||
)
|
||||
|
||||
resolved_url = (
|
||||
base_url
|
||||
if is_given(base_url)
|
||||
else os.environ.get(
|
||||
"VOXTRAL_VLLM_BASE_URL", "ws://127.0.0.1:8000/v1/realtime"
|
||||
)
|
||||
)
|
||||
resolved_model = (
|
||||
model
|
||||
if is_given(model)
|
||||
else os.environ.get(
|
||||
"VOXTRAL_VLLM_MODEL", "mistralai/Voxtral-Mini-4B-Realtime-2602"
|
||||
)
|
||||
)
|
||||
resolved_key = (
|
||||
api_key if is_given(api_key) else os.environ.get("VOXTRAL_VLLM_API_KEY")
|
||||
)
|
||||
resolved_delay = (
|
||||
target_streaming_delay_ms
|
||||
if is_given(target_streaming_delay_ms)
|
||||
else (
|
||||
int(os.environ["VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS"])
|
||||
if os.environ.get("VOXTRAL_VLLM_TARGET_STREAMING_DELAY_MS")
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
if vad is None:
|
||||
try:
|
||||
from livekit.plugins.silero import VAD as SileroVAD # noqa: PLC0415
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"livekit-plugins-silero is required for vLLM Voxtral realtime "
|
||||
"(no server-side endpointing)."
|
||||
) from exc
|
||||
vad = SileroVAD.load()
|
||||
self._vad = vad
|
||||
|
||||
self._opts = _STTOptions(
|
||||
base_url=resolved_url,
|
||||
model=resolved_model,
|
||||
api_key=resolved_key,
|
||||
target_streaming_delay_ms=resolved_delay,
|
||||
)
|
||||
self._streams: weakref.WeakSet[SpeechStream] = weakref.WeakSet()
|
||||
|
||||
@property
|
||||
def model(self) -> str:
|
||||
"""Return the configured vLLM model name."""
|
||||
return self._opts.model
|
||||
|
||||
@property
|
||||
def provider(self) -> str:
|
||||
"""Return the provider identifier."""
|
||||
return "vllm-voxtral-realtime"
|
||||
|
||||
async def _recognize_impl(self, *_args, **_kwargs) -> stt.SpeechEvent:
|
||||
raise NotImplementedError(
|
||||
"vLLM Voxtral Realtime STT only supports streaming recognition."
|
||||
)
|
||||
|
||||
def stream(
|
||||
self,
|
||||
*,
|
||||
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
|
||||
) -> SpeechStream:
|
||||
"""Open a new streaming recognition stream."""
|
||||
s = SpeechStream(
|
||||
stt=self,
|
||||
opts=self._opts,
|
||||
vad_instance=self._vad,
|
||||
conn_options=conn_options,
|
||||
)
|
||||
self._streams.add(s)
|
||||
return s
|
||||
|
||||
|
||||
class SpeechStream(stt.RecognizeStream):
|
||||
"""Voxtral realtime handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
stt: STT,
|
||||
opts: _STTOptions,
|
||||
vad_instance: vad_module.VAD,
|
||||
conn_options: APIConnectOptions,
|
||||
) -> None:
|
||||
"""Init the speech stream."""
|
||||
super().__init__(stt=stt, conn_options=conn_options, sample_rate=SAMPLE_RATE)
|
||||
self._opts = opts
|
||||
self._vad = vad_instance
|
||||
self._utterance_q: asyncio.Queue[bytes | None] | None = None
|
||||
self._speaking = False
|
||||
self._preroll: deque[bytes] = deque(maxlen=PREROLL_CHUNKS)
|
||||
# Voxtral realtime is strictly sequential: only one generation runs at a
|
||||
# time, and a new `commit` is ignored while the previous one is still
|
||||
# producing. We queue per-utterance audio buffers here and let the
|
||||
# pipeline process them one by one on the shared websocket.
|
||||
self._utterance_chan: asyncio.Queue[asyncio.Queue[bytes | None] | None] = (
|
||||
asyncio.Queue()
|
||||
)
|
||||
|
||||
@utils.log_exceptions(logger=logger)
|
||||
async def _run(self) -> None:
|
||||
vad_stream = self._vad.stream()
|
||||
|
||||
bstream = utils.audio.AudioByteStream(
|
||||
sample_rate=SAMPLE_RATE,
|
||||
num_channels=NUM_CHANNELS,
|
||||
samples_per_channel=CHUNK_SAMPLES,
|
||||
)
|
||||
|
||||
async def input_task() -> None:
|
||||
async for data in self._input_ch:
|
||||
if isinstance(data, self._FlushSentinel):
|
||||
for frame in bstream.flush():
|
||||
self._handle_chunk(frame.data.tobytes())
|
||||
continue
|
||||
|
||||
vad_stream.push_frame(data)
|
||||
for frame in bstream.write(data.data.tobytes()):
|
||||
self._handle_chunk(frame.data.tobytes())
|
||||
|
||||
vad_stream.end_input()
|
||||
|
||||
async def vad_task() -> None:
|
||||
async for ev in vad_stream:
|
||||
if ev.type == vad_module.VADEventType.START_OF_SPEECH:
|
||||
self._on_start_of_speech()
|
||||
elif ev.type == vad_module.VADEventType.END_OF_SPEECH:
|
||||
self._on_end_of_speech()
|
||||
|
||||
pipeline_t = asyncio.create_task(self._utterance_pipeline())
|
||||
try:
|
||||
await asyncio.gather(input_task(), vad_task())
|
||||
# signal end-of-stream; pipeline finishes pending utterances first
|
||||
self._utterance_chan.put_nowait(None)
|
||||
await pipeline_t
|
||||
except (APIStatusError, APIConnectionError, asyncio.CancelledError):
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("vLLM realtime stream failed")
|
||||
raise APIConnectionError() from exc
|
||||
finally:
|
||||
if not pipeline_t.done():
|
||||
pipeline_t.cancel()
|
||||
try:
|
||||
await pipeline_t
|
||||
except asyncio.CancelledError:
|
||||
# CancelledError is the expected flow on cancel()
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("utterance pipeline failed during finalize")
|
||||
await vad_stream.aclose()
|
||||
|
||||
def _handle_chunk(self, chunk: bytes) -> None:
|
||||
self._preroll.append(chunk)
|
||||
if self._speaking and self._utterance_q is not None:
|
||||
self._utterance_q.put_nowait(chunk)
|
||||
|
||||
def _on_start_of_speech(self) -> None:
|
||||
if self._speaking:
|
||||
return
|
||||
self._speaking = True
|
||||
q: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||
for chunk in self._preroll:
|
||||
q.put_nowait(chunk)
|
||||
self._utterance_q = q
|
||||
self._utterance_chan.put_nowait(q)
|
||||
self._event_ch.send_nowait(
|
||||
stt.SpeechEvent(type=stt.SpeechEventType.START_OF_SPEECH)
|
||||
)
|
||||
|
||||
def _on_end_of_speech(self) -> None:
|
||||
if not self._speaking:
|
||||
return
|
||||
self._speaking = False
|
||||
if self._utterance_q is not None:
|
||||
self._utterance_q.put_nowait(None)
|
||||
self._utterance_q = None
|
||||
self._event_ch.send_nowait(
|
||||
stt.SpeechEvent(type=stt.SpeechEventType.END_OF_SPEECH)
|
||||
)
|
||||
|
||||
async def _handshake(self, ws: websockets.ClientConnection) -> str:
|
||||
created = json.loads(await ws.recv())
|
||||
if created.get("type") != "session.created":
|
||||
raise APIStatusError(
|
||||
f"expected session.created, got {created}",
|
||||
status_code=500,
|
||||
body=created,
|
||||
)
|
||||
session_update: dict = {"type": "session.update", "model": self._opts.model}
|
||||
if self._opts.target_streaming_delay_ms is not None:
|
||||
session_update["target_streaming_delay_ms"] = (
|
||||
self._opts.target_streaming_delay_ms
|
||||
)
|
||||
await ws.send(json.dumps(session_update))
|
||||
return created.get("id", "")
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
if self._opts.api_key:
|
||||
return {"Authorization": f"Bearer {self._opts.api_key}"}
|
||||
return {}
|
||||
|
||||
async def _utterance_pipeline(self) -> None:
|
||||
# Owns the websocket lifecycle. On drop, reopens and resumes the
|
||||
# in-flight utterance (if any) by replaying its already-sent chunks.
|
||||
pending: _PendingUtterance | None = None
|
||||
attempt = 0
|
||||
while True:
|
||||
try:
|
||||
async with websockets.connect(
|
||||
self._opts.base_url,
|
||||
additional_headers=self._auth_headers(),
|
||||
open_timeout=self._conn_options.timeout,
|
||||
) as ws:
|
||||
request_id = await self._handshake(ws)
|
||||
attempt = 0
|
||||
while True:
|
||||
if pending is None:
|
||||
q = await self._utterance_chan.get()
|
||||
if q is None:
|
||||
return
|
||||
pending = _PendingUtterance(queue=q)
|
||||
await self._process_utterance(ws, pending, request_id)
|
||||
pending = None
|
||||
except (websockets.WebSocketException, OSError, TimeoutError) as exc:
|
||||
attempt += 1
|
||||
if attempt > RECONNECT_MAX_ATTEMPTS:
|
||||
logger.exception(
|
||||
"vLLM realtime: giving up after %d reconnect attempts",
|
||||
RECONNECT_MAX_ATTEMPTS,
|
||||
)
|
||||
raise APIConnectionError() from exc
|
||||
backoff = min(
|
||||
RECONNECT_BACKOFF_BASE_S * (2 ** (attempt - 1)),
|
||||
RECONNECT_BACKOFF_MAX_S,
|
||||
)
|
||||
if pending is None:
|
||||
logger.warning(
|
||||
"vLLM WS connection lost between utterances "
|
||||
"(attempt %d/%d): %s; retrying in %.1fs",
|
||||
attempt,
|
||||
RECONNECT_MAX_ATTEMPTS,
|
||||
exc,
|
||||
backoff,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"vLLM WS dropped mid-utterance (%d chunks buffered, "
|
||||
"ended=%s, attempt %d/%d): %s; retrying in %.1fs",
|
||||
len(pending.sent_chunks),
|
||||
pending.ended,
|
||||
attempt,
|
||||
RECONNECT_MAX_ATTEMPTS,
|
||||
exc,
|
||||
backoff,
|
||||
)
|
||||
await asyncio.sleep(backoff)
|
||||
|
||||
async def _process_utterance(
|
||||
self,
|
||||
ws: websockets.ClientConnection,
|
||||
pending: _PendingUtterance,
|
||||
request_id: str,
|
||||
) -> None:
|
||||
# Start a fresh generation. Safe to send here: the previous utterance's
|
||||
# transcription.done has already been received (we await it below), so
|
||||
# the server-side generation_task is done and won't ignore this commit.
|
||||
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
|
||||
send_t = asyncio.create_task(self._send_audio(ws, pending))
|
||||
try:
|
||||
await self._receive_one_transcription(ws, request_id)
|
||||
finally:
|
||||
if not send_t.done():
|
||||
send_t.cancel()
|
||||
try:
|
||||
await send_t
|
||||
except (asyncio.CancelledError, websockets.WebSocketException):
|
||||
pass
|
||||
except Exception:
|
||||
logger.exception("send-audio task failed during finalize")
|
||||
|
||||
@staticmethod
|
||||
async def _send_audio(
|
||||
ws: websockets.ClientConnection, pending: _PendingUtterance
|
||||
) -> None:
|
||||
# Replay anything already sent on a previous (now-dead) connection.
|
||||
# sent_chunks is appended before send, so a chunk that failed to send
|
||||
# last time is still present and gets retried here.
|
||||
for chunk in pending.sent_chunks:
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(chunk).decode("ascii"),
|
||||
}
|
||||
)
|
||||
)
|
||||
if pending.ended:
|
||||
await ws.send(
|
||||
json.dumps({"type": "input_audio_buffer.commit", "final": True})
|
||||
)
|
||||
return
|
||||
while True:
|
||||
chunk = await pending.queue.get()
|
||||
if chunk is None:
|
||||
pending.ended = True
|
||||
await ws.send(
|
||||
json.dumps({"type": "input_audio_buffer.commit", "final": True})
|
||||
)
|
||||
return
|
||||
pending.sent_chunks.append(chunk)
|
||||
await ws.send(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "input_audio_buffer.append",
|
||||
"audio": base64.b64encode(chunk).decode("ascii"),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
async def _receive_one_transcription(
|
||||
self, ws: websockets.ClientConnection, request_id: str
|
||||
) -> None:
|
||||
# Use recv() rather than `async for`: the latter swallows
|
||||
# ConnectionClosed on close-mid-iteration, which would let a dropped
|
||||
# WS look like a clean "no transcription" return.
|
||||
current_text = ""
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
data = json.loads(raw)
|
||||
event_type = data.get("type")
|
||||
|
||||
if event_type == "transcription.delta":
|
||||
delta = data.get("delta", "")
|
||||
if not delta:
|
||||
continue
|
||||
current_text += delta
|
||||
self._event_ch.send_nowait(
|
||||
stt.SpeechEvent(
|
||||
type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
|
||||
request_id=request_id,
|
||||
alternatives=[stt.SpeechData(text=current_text, language="")],
|
||||
)
|
||||
)
|
||||
elif event_type == "transcription.done":
|
||||
final_text = data.get("text") or current_text
|
||||
self._event_ch.send_nowait(
|
||||
stt.SpeechEvent(
|
||||
type=stt.SpeechEventType.FINAL_TRANSCRIPT,
|
||||
request_id=request_id,
|
||||
alternatives=[stt.SpeechData(text=final_text, language="")],
|
||||
)
|
||||
)
|
||||
usage = data.get("usage") or {}
|
||||
self._event_ch.send_nowait(
|
||||
stt.SpeechEvent(
|
||||
type=stt.SpeechEventType.RECOGNITION_USAGE,
|
||||
request_id=request_id,
|
||||
recognition_usage=stt.RecognitionUsage(
|
||||
audio_duration=float(
|
||||
usage.get("audio_seconds")
|
||||
or usage.get("prompt_audio_seconds")
|
||||
or 0
|
||||
),
|
||||
input_tokens=int(usage.get("prompt_tokens") or 0),
|
||||
output_tokens=int(usage.get("completion_tokens") or 0),
|
||||
),
|
||||
)
|
||||
)
|
||||
return
|
||||
elif event_type == "error":
|
||||
err = data.get("error")
|
||||
raise APIStatusError(str(err), status_code=500, body=data)
|
||||
@@ -11,7 +11,6 @@ class FeatureFlag:
|
||||
|
||||
FLAGS = {
|
||||
"recording": "RECORDING_ENABLE",
|
||||
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.http import Http404
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
from ..models import RoleChoices, RoomAccessLevel
|
||||
from ..services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
@@ -198,3 +198,48 @@ class IsPresentInMeeting(permissions.BasePermission):
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
|
||||
class CanManageLobby(permissions.BasePermission):
|
||||
"""Grant lobby management (list/accept/deny waiting participants).
|
||||
|
||||
- Room admins/owners can always manage the lobby.
|
||||
- When the room access level is TRUSTED, any authenticated user who is
|
||||
currently connected to the meeting can manage the lobby. Presence is
|
||||
verified cache-first (Redis), falling back to the LiveKit API.
|
||||
|
||||
Access level is always read fresh from the DB; only presence is cached,
|
||||
so changing the room to RESTRICTED takes effect immediately.
|
||||
"""
|
||||
|
||||
message = "You are not allowed to manage this room's lobby."
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def has_object_permission(self, request, view, obj): # noqa: PLR0911
|
||||
"""Check privileges first, then the trusted-room presence path."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Product choice: lobby management is reserved for session-authenticated
|
||||
# users with a real account, not holders of a LiveKit room token.
|
||||
if request.auth and hasattr(request.auth, "video"):
|
||||
return False
|
||||
|
||||
if obj.is_administrator_or_owner(user):
|
||||
return True
|
||||
|
||||
if obj.access_level != RoomAccessLevel.TRUSTED:
|
||||
return False
|
||||
|
||||
self.message = "You must be connected to the meeting to manage its lobby."
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting_cached(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
@@ -45,25 +45,11 @@ from core.api import throttling
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import (
|
||||
RecordingProcessWebhookAuthentication,
|
||||
StorageEventAuthentication,
|
||||
)
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
@@ -536,7 +522,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="enter",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.CanManageLobby,
|
||||
],
|
||||
)
|
||||
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -574,7 +560,7 @@ class RoomViewSet(
|
||||
methods=["GET"],
|
||||
url_path="waiting-participants",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.CanManageLobby,
|
||||
],
|
||||
)
|
||||
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
|
||||
@@ -1034,56 +1020,6 @@ class RecordingViewSet(
|
||||
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="storage-hook",
|
||||
authentication_classes=[StorageEventAuthentication],
|
||||
)
|
||||
@FeatureFlag.require("storage_event")
|
||||
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Handle incoming storage hook events for recordings."""
|
||||
|
||||
parser = get_parser()
|
||||
|
||||
try:
|
||||
recording_id = parser.get_recording_id(request.data)
|
||||
|
||||
except ParsingEventDataError as e:
|
||||
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
|
||||
|
||||
except InvalidBucketError as e:
|
||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
|
||||
|
||||
except InvalidFilepathError:
|
||||
return drf_response.Response(
|
||||
{"message": "Notification ignored."},
|
||||
)
|
||||
|
||||
except InvalidFileTypeError:
|
||||
return drf_response.Response(
|
||||
{"message": "Notification ignored."},
|
||||
)
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
# Save recording
|
||||
recording_events_service = RecordingEventsService()
|
||||
try:
|
||||
recording_events_service.handle_complete(recording)
|
||||
except RecordingNotSavableError:
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
||||
" in an error state or has already been saved."
|
||||
) from None
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Authentication class for storage event token validation."""
|
||||
"""Authentication classes for server-to-server webhook token validation."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
@@ -12,9 +12,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MachineUser:
|
||||
"""Represent a non-interactive system user for automated storage operations."""
|
||||
"""Represent a non-interactive system user for automated operations."""
|
||||
|
||||
def __init__(self, username: str = "storage_event_user") -> None:
|
||||
def __init__(self, username: str = "machine_user") -> None:
|
||||
self.pk = None
|
||||
self.username = username
|
||||
self.is_active = True
|
||||
@@ -41,24 +41,17 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
REALM = ""
|
||||
|
||||
IS_ENFORCED_SETTINGS_KEY = None
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header."""
|
||||
|
||||
if self.IS_ENFORCED_SETTINGS_KEY is not None:
|
||||
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
|
||||
return MachineUser(), None
|
||||
|
||||
if (
|
||||
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
||||
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
||||
is None
|
||||
):
|
||||
raise AuthenticationFailed(
|
||||
"Authentication is enabled but token is not configured."
|
||||
)
|
||||
raise AuthenticationFailed("Authentication token is not configured.")
|
||||
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
if not auth_header:
|
||||
@@ -88,18 +81,6 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
||||
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
|
||||
|
||||
|
||||
class StorageEventAuthentication(HeaderBasedAuthentication):
|
||||
"""Authenticate requests using a Bearer token for storage event integration.
|
||||
This class validates Bearer tokens for storage events that don't map to database users.
|
||||
It's designed for S3-compatible storage integrations and similar use cases.
|
||||
Events are submitted when a webhook is configured on some bucket's events.
|
||||
"""
|
||||
|
||||
REALM = "Storage event API"
|
||||
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
|
||||
|
||||
|
||||
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||
"""
|
||||
Custom authentication class for recording process webhook requests.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
"""Storage parsers specific exceptions."""
|
||||
|
||||
|
||||
class ParsingEventDataError(Exception):
|
||||
"""Raised when the request data is malformed, incomplete, or missing."""
|
||||
|
||||
|
||||
class InvalidBucketError(Exception):
|
||||
"""Raised when the bucket name in the request does not match the expected one."""
|
||||
|
||||
|
||||
class InvalidFileTypeError(Exception):
|
||||
"""Raised when the file type in the request is not supported."""
|
||||
|
||||
|
||||
class InvalidFilepathError(Exception):
|
||||
"""Raised when the filepath in the request is invalid."""
|
||||
@@ -1,178 +0,0 @@
|
||||
"""Meet storage event parser classes."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from core.enums import FILE_EXT_REGEX, UUID_REGEX
|
||||
|
||||
from .exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
|
||||
# Additional MIME type mapping
|
||||
mimetypes.add_type("audio/ogg", ".ogg")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageEvent:
|
||||
"""Represents a storage event with relevant metadata.
|
||||
Attributes:
|
||||
filepath: Identifier for the affected recording
|
||||
filetype: Type of storage event
|
||||
bucket_name: When the event occurred
|
||||
metadata: Additional event data
|
||||
"""
|
||||
|
||||
filepath: str
|
||||
filetype: str
|
||||
bucket_name: str
|
||||
metadata: Optional[Dict[str, Any]]
|
||||
|
||||
def __post_init__(self):
|
||||
if self.filepath is None:
|
||||
raise TypeError("filepath cannot be None")
|
||||
if self.filetype is None:
|
||||
raise TypeError("filetype cannot be None")
|
||||
if self.bucket_name is None:
|
||||
raise TypeError("bucket_name cannot be None")
|
||||
|
||||
|
||||
class EventParser(Protocol):
|
||||
"""Interface for parsing storage events."""
|
||||
|
||||
def __init__(self, bucket_name, allowed_filetypes=None):
|
||||
"""Initialize parser with bucket name and optional allowed filetypes."""
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
"""Extract storage event data from raw dictionary input."""
|
||||
|
||||
def validate(self, data: StorageEvent) -> str:
|
||||
"""Verify storage event data meets all requirements."""
|
||||
|
||||
def get_recording_id(self, data: Dict) -> str:
|
||||
"""Extract recording ID from event dictionary."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_parser() -> EventParser:
|
||||
"""Return cached instance of configured event parser.
|
||||
Uses function memoization instead of a factory class since the only
|
||||
varying parameter is the parser class from settings. A factory class
|
||||
would add unnecessary complexity when a cached function provides the
|
||||
same singleton behavior with simpler code.
|
||||
"""
|
||||
|
||||
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
|
||||
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
|
||||
|
||||
|
||||
class BaseS3Parser:
|
||||
"""Base class for handling parsing and validation of S3-compatible storage events."""
|
||||
|
||||
def __init__(self, bucket_name: str, allowed_filetypes=None):
|
||||
"""Initialize parser with target bucket name and accepted filetypes."""
|
||||
|
||||
if not bucket_name:
|
||||
raise ValueError("Bucket name cannot be None or empty")
|
||||
|
||||
self._bucket_name = bucket_name
|
||||
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
self._filepath_regex = re.compile(
|
||||
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
|
||||
)
|
||||
|
||||
def validate(self, event_data: StorageEvent) -> str:
|
||||
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
|
||||
|
||||
if event_data.bucket_name != self._bucket_name:
|
||||
raise InvalidBucketError(
|
||||
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
|
||||
)
|
||||
|
||||
if event_data.filetype not in self._allowed_filetypes:
|
||||
raise InvalidFileTypeError(
|
||||
f"Invalid file type, expected {self._allowed_filetypes},"
|
||||
f"got '{event_data.filetype}'"
|
||||
)
|
||||
|
||||
match = self._filepath_regex.match(event_data.filepath)
|
||||
if not match:
|
||||
raise InvalidFilepathError(
|
||||
f"Invalid filepath structure: {event_data.filepath}"
|
||||
)
|
||||
|
||||
recording_id = match.group("recording_id")
|
||||
return recording_id
|
||||
|
||||
def get_recording_id(self, data):
|
||||
"""Extract recording ID from S3 event through parsing and validation."""
|
||||
|
||||
event_data = self.parse(data)
|
||||
return self.validate(event_data)
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
"""To be implemented by subclasses."""
|
||||
raise NotImplementedError("Subclasses must implement parse()")
|
||||
|
||||
|
||||
class MinioParser(BaseS3Parser):
|
||||
"""Minio specific event parsing."""
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
try:
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
return StorageEvent(
|
||||
filepath=s3["object"]["key"],
|
||||
filetype=s3["object"]["contentType"], # Minio-specific field
|
||||
bucket_name=s3["bucket"]["name"],
|
||||
metadata=None,
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Malformed Minio event: {e}") from e
|
||||
except TypeError as e:
|
||||
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
||||
|
||||
|
||||
class S3Parser(BaseS3Parser):
|
||||
"""AWS S3 specific event parsing."""
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
try:
|
||||
# AWS S3 structure can slightly differ from Minio implementation
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
filepath = s3["object"]["key"]
|
||||
if not filepath:
|
||||
raise ParsingEventDataError("Missing object key name")
|
||||
filetype, _ = mimetypes.guess_type(filepath)
|
||||
# Normalize raw S3-compatible object keys without re-encoding
|
||||
# already encoded AWS S3 notification keys.
|
||||
filepath = quote(filepath, safe="%+")
|
||||
return StorageEvent(
|
||||
filepath=filepath,
|
||||
filetype=filetype,
|
||||
bucket_name=s3["bucket"]["name"],
|
||||
metadata=None,
|
||||
)
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Malformed S3 event: {e}") from e
|
||||
@@ -23,6 +23,7 @@ from core.recording.services.recording_events import (
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .presence import PresenceCache
|
||||
from .room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
@@ -99,6 +100,7 @@ class LiveKitEventsService:
|
||||
"egress_ended": self._handle_egress_ended,
|
||||
"room_started": self._handle_room_started,
|
||||
"room_finished": self._handle_room_finished,
|
||||
"participant_left": self._handle_participant_left,
|
||||
}
|
||||
|
||||
token_verifier = api.TokenVerifier(
|
||||
@@ -107,6 +109,7 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.presence_cache = PresenceCache()
|
||||
self.sip_management = SIPManagement()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
@@ -217,10 +220,8 @@ class LiveKitEventsService:
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
|
||||
# Fallback for completion when no MinIO/S3 webhooks are configured
|
||||
if (
|
||||
not settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||
) and data.egress_info.status in [
|
||||
# Finalize the recording, the egress has uploaded the file to the storage
|
||||
if data.egress_info.status in [
|
||||
api.EgressStatus.EGRESS_COMPLETE,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
]:
|
||||
@@ -285,9 +286,31 @@ class LiveKitEventsService:
|
||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
self.presence_cache.clear_room(room_id)
|
||||
|
||||
try:
|
||||
self.lobby_service.clear_room_cache(room_id)
|
||||
except Exception as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to clear room cache for room {room_id}"
|
||||
) from e
|
||||
|
||||
def _handle_participant_left(self, data):
|
||||
"""Handle 'participant_left': invalidate the presence cache.
|
||||
|
||||
Presence entries are created lazily (only for users who administrate
|
||||
the lobby of a trusted room), so for most participants this delete is
|
||||
a no-op DEL on a key that never existed. Eager invalidation shrinks
|
||||
the window during which a departed participant could still act on a
|
||||
trusted room's lobby (cache hit until TTL expiry). It is gated behind
|
||||
`PRESENCE_CLEAR_ON_PARTICIPANT_LEFT` so its production impact can be
|
||||
measured and the behaviour reverted independently of the feature.
|
||||
When disabled, invalidation relies on `room_finished` and the TTL.
|
||||
"""
|
||||
if not settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT:
|
||||
return
|
||||
|
||||
identity = data.participant.identity
|
||||
if not identity:
|
||||
return
|
||||
self.presence_cache.clear(data.room.name, identity)
|
||||
|
||||
@@ -270,7 +270,7 @@ class LobbyService:
|
||||
"""List all waiting participants for a room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = cache.keys(pattern)
|
||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
||||
|
||||
if not keys:
|
||||
return []
|
||||
@@ -345,13 +345,9 @@ class LobbyService:
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = cache.keys(pattern)
|
||||
|
||||
if not keys:
|
||||
return
|
||||
|
||||
cache.delete_many(keys)
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
|
||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Clear a given participant entry from the cache for a specific room."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from livekit.protocol.models import ParticipantInfo
|
||||
from core import utils
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .presence import PresenceCache
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -72,7 +73,9 @@ class ParticipantsManagement:
|
||||
|
||||
@async_to_sync
|
||||
async def remove(self, room_name: str, identity: str):
|
||||
"""Remove a participant from a room and clear their lobby cache."""
|
||||
"""Remove a participant from a room and clear their lobby/presence cache."""
|
||||
|
||||
PresenceCache().clear(room_name, identity)
|
||||
|
||||
try:
|
||||
LobbyService().clear_participant_cache(
|
||||
@@ -156,6 +159,30 @@ class ParticipantsManagement:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def check_if_in_meeting_cached(self, room_name: str, identity: str) -> bool:
|
||||
"""Cache-first variant of `check_if_in_meeting`.
|
||||
|
||||
Cache hit -> True without touching LiveKit.
|
||||
Cache miss -> ask LiveKit; memoize only positive answers.
|
||||
|
||||
Raises the same exceptions as `check_if_in_meeting` so callers keep
|
||||
failing closed the same way.
|
||||
"""
|
||||
if not room_name or not identity:
|
||||
return False
|
||||
|
||||
presence_cache = PresenceCache()
|
||||
|
||||
if presence_cache.is_marked_present(room_name, identity):
|
||||
return True
|
||||
|
||||
present = self.check_if_in_meeting(room_name=room_name, identity=identity)
|
||||
|
||||
if present:
|
||||
presence_cache.mark_present(room_name, identity)
|
||||
|
||||
return present
|
||||
|
||||
@async_to_sync
|
||||
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
|
||||
"""Check whether `identity` is currently a participant in `room_name`.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Presence cache.
|
||||
|
||||
Redis-backed memo of "this identity is currently connected to this room".
|
||||
|
||||
This module is intentionally a *pure cache store* with no dependency on other
|
||||
services, so that `participants_management` (which talks to LiveKit) can
|
||||
import it without creating an import cycle. The composition of "check cache,
|
||||
fall back to LiveKit" lives in
|
||||
`ParticipantsManagement.check_if_in_meeting_cached`.
|
||||
|
||||
Only positive answers are stored: a sticky negative would lock out someone
|
||||
who joins right after a miss for the whole TTL. The TTL is a safety net in
|
||||
case an invalidation webhook is lost.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from core.utils import CACHE_SCAN_ITERSIZE
|
||||
|
||||
|
||||
class PresenceCache:
|
||||
"""Store and invalidate (room, identity) presence entries."""
|
||||
|
||||
@staticmethod
|
||||
def _get_cache_key(room_id: UUID | str, identity: str) -> str:
|
||||
"""Cache key for a (room, identity) presence entry."""
|
||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
||||
|
||||
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||
"""Return True if a positive presence entry exists in cache."""
|
||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
||||
|
||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Record that `identity` is in `room_id`."""
|
||||
cache.set(
|
||||
self._get_cache_key(room_id, identity),
|
||||
True,
|
||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||
cache.delete(self._get_cache_key(room_id, identity))
|
||||
|
||||
def clear_room(self, room_id: UUID | str) -> None:
|
||||
"""Forget presence for every participant of a room (on room_finished)."""
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
@@ -11,135 +11,98 @@ from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core.recording.event.authentication import (
|
||||
MachineUser,
|
||||
StorageEventAuthentication,
|
||||
RecordingProcessWebhookAuthentication,
|
||||
)
|
||||
|
||||
|
||||
def test_successful_authentication(settings):
|
||||
"""Test successful authentication with valid token."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer valid-test-token"}
|
||||
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
assert token == "valid-test-token"
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
def test_disabled_authentication_with_header(settings):
|
||||
"""Authentication should pass when no auth is configured, and header is present."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer some-token"}
|
||||
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token is None
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
def test_disabled_authentication_without_header(settings):
|
||||
"""Authentication should pass when no auth is configured, and no header is present."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token is None
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
def test_authentication_when_disabled(settings):
|
||||
"""Authentication should pass when disabled, regardless of token configuration."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
|
||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token is None
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
def test_authentication_fails_when_token_not_configured(settings):
|
||||
"""Authentication should fail when authentication is enabled but no token is configured."""
|
||||
"""Authentication should fail when no token is configured."""
|
||||
|
||||
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = None
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
|
||||
with pytest.raises(
|
||||
AuthenticationFailed,
|
||||
match="Authentication is enabled but token is not configured",
|
||||
match="Authentication token is not configured",
|
||||
):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_missing_auth_header(settings):
|
||||
"""Test failure when Authorization header is missing."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_invalid_auth_header_format(settings):
|
||||
"""Test failure when Authorization header has invalid format."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "InvalidFormat"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_invalid_token_type(settings):
|
||||
"""Test failure when token type is not Bearer."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Basic some-token"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_invalid_token(settings):
|
||||
"""Test failure when token is invalid."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer wrong-token"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid token"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_malformed_auth_header(settings):
|
||||
"""Test failure when Authorization header is malformed."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer"} # Missing token part
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_authenticate_header():
|
||||
"""Test the WWW-Authenticate header value."""
|
||||
request = RequestFactory().get("/")
|
||||
header = StorageEventAuthentication().authenticate_header(request)
|
||||
assert header == "Bearer realm='Storage event API'"
|
||||
header = RecordingProcessWebhookAuthentication().authenticate_header(request)
|
||||
assert header == "Bearer realm='External process webhook API'"
|
||||
|
||||
|
||||
def test_multiple_spaces_in_auth_header(settings):
|
||||
"""Test success when Authorization header contains multiple spaces."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
"""Extra spaces between the scheme and the token should be tolerated."""
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "extra-spaces-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||
|
||||
header = StorageEventAuthentication().authenticate_header(request)
|
||||
assert header == "Bearer realm='Storage event API'"
|
||||
user, token = RecordingProcessWebhookAuthentication().authenticate(request)
|
||||
assert token == "extra-spaces-token"
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
@@ -1,512 +0,0 @@
|
||||
"""
|
||||
Test event parsers.
|
||||
"""
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
from core.recording.event.parsers import (
|
||||
MinioParser,
|
||||
S3Parser,
|
||||
StorageEvent,
|
||||
get_parser,
|
||||
)
|
||||
|
||||
# MinioParser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_minio_event():
|
||||
"""Mock a valid Minio event."""
|
||||
return {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"contentType": "audio/ogg",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minio_parser():
|
||||
"""Mock a Minio parser."""
|
||||
return MinioParser(bucket_name="test-bucket")
|
||||
|
||||
|
||||
def test_minio_parse_valid_event(minio_parser, valid_minio_event):
|
||||
"""Test parsing a valid Minio event."""
|
||||
event = minio_parser.parse(valid_minio_event)
|
||||
assert isinstance(event, StorageEvent)
|
||||
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||
assert event.filetype == "audio/ogg"
|
||||
assert event.bucket_name == "test-bucket"
|
||||
assert event.metadata is None
|
||||
|
||||
|
||||
def test_minio_parse_with_video_type(minio_parser):
|
||||
"""Test parsing event with video file type."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
"contentType": "video/mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = minio_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_minio_parse_empty_data(minio_parser):
|
||||
"""Test parsing empty event data raises error."""
|
||||
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||
minio_parser.parse({})
|
||||
|
||||
|
||||
def test_minio_parse_missing_keys(minio_parser):
|
||||
"""Test parsing event with missing key."""
|
||||
|
||||
invalid_minio_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": None},
|
||||
# Missing 'object' key
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Malformed Minio event:"):
|
||||
minio_parser.parse(invalid_minio_event)
|
||||
|
||||
|
||||
def test_minio_parse_none_key(minio_parser):
|
||||
"""Test parsing event with None field."""
|
||||
|
||||
invalid_minio_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"contentType": None, # 'contentType' should not be None
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
|
||||
minio_parser.parse(invalid_minio_event)
|
||||
|
||||
|
||||
def test_minio_validate_invalid_bucket(minio_parser):
|
||||
"""Test validation with wrong bucket name."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/ogg",
|
||||
bucket_name="wrong-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
with pytest.raises(InvalidBucketError):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
def test_minio_validate_invalid_filetype(minio_parser):
|
||||
"""Test validation with unsupported file type."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
|
||||
filetype="text/plain", # Not included in the default allowed filetypes
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
with pytest.raises(InvalidFileTypeError):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_filepath",
|
||||
[
|
||||
"invalid_filepath", # totally invalid string
|
||||
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
||||
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
|
||||
"", # empty string
|
||||
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
|
||||
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
|
||||
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
|
||||
],
|
||||
)
|
||||
def test_minio_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
"""Test validation with malformed filepath."""
|
||||
event = StorageEvent(
|
||||
filepath=invalid_filepath,
|
||||
filetype="audio/ogg",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
with pytest.raises(InvalidFilepathError):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
def test_minio_validate_valid_event(minio_parser):
|
||||
"""Test validation with valid event data."""
|
||||
event = StorageEvent(
|
||||
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/ogg",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
recording_id = minio_parser.validate(event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_minio_get_recording_id_success(minio_parser, valid_minio_event):
|
||||
"""Test successful extraction of recording ID."""
|
||||
recording_id = minio_parser.get_recording_id(valid_minio_event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_minio_validate_filepath_with_folder(minio_parser):
|
||||
"""Test validation of filepath with folder structure."""
|
||||
event = StorageEvent(
|
||||
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/ogg",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
recording_id = minio_parser.validate(event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_minio_empty_allowed_filetypes():
|
||||
"""Test MinioParser with empty allowed_filetypes."""
|
||||
empty_types = set()
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
|
||||
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
|
||||
|
||||
|
||||
def test_minio_custom_allowed_filetypes():
|
||||
"""Test MinioParser with empty allowed_filetypes."""
|
||||
custom_types = {"audio/mp3", "video/mov"}
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
|
||||
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
|
||||
|
||||
|
||||
def test_minio_validate_custom_filetypes():
|
||||
"""Test validation of filepath with folder structure."""
|
||||
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||
|
||||
event = StorageEvent(
|
||||
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/mp3",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
parser.validate(event)
|
||||
|
||||
|
||||
def test_minio_constructor_none_bucket():
|
||||
"""Test MinioParser constructor with None bucket name."""
|
||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||
MinioParser(bucket_name=None)
|
||||
|
||||
|
||||
def test_minio_constructor_empty_bucket():
|
||||
"""Test MinioParser constructor with empty bucket name."""
|
||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||
MinioParser(bucket_name="")
|
||||
|
||||
|
||||
# S3Parser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_s3_event():
|
||||
"""Mock a valid S3 event."""
|
||||
return {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_parser():
|
||||
"""Mock an S3 parser."""
|
||||
return S3Parser(bucket_name="test-bucket")
|
||||
|
||||
|
||||
def test_s3_parse_valid_event(s3_parser, valid_s3_event):
|
||||
"""Test parsing a valid S3 event."""
|
||||
event = s3_parser.parse(valid_s3_event)
|
||||
assert isinstance(event, StorageEvent)
|
||||
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||
assert event.filetype == "audio/ogg"
|
||||
assert event.bucket_name == "test-bucket"
|
||||
assert event.metadata is None
|
||||
|
||||
|
||||
def test_s3_parse_empty_data(s3_parser):
|
||||
"""Test parsing empty S3 event data raises error."""
|
||||
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||
s3_parser.parse({})
|
||||
|
||||
|
||||
def test_s3_parse_missing_keys(s3_parser):
|
||||
"""Test parsing S3 event with missing key."""
|
||||
invalid_s3_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
# Missing 'object' key
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Malformed S3 event:"):
|
||||
s3_parser.parse(invalid_s3_event)
|
||||
|
||||
|
||||
def test_s3_parse_none_key(s3_parser):
|
||||
"""Test parsing S3 event with None field."""
|
||||
invalid_s3_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": None,
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Missing object key name"):
|
||||
s3_parser.parse(invalid_s3_event)
|
||||
|
||||
|
||||
def test_s3_parse_with_video_type(s3_parser):
|
||||
"""Test parsing S3 event with mp4 file extension."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = s3_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_s3_parse_unrecognized_extension(s3_parser):
|
||||
"""Test parsing S3 event with unrecognized file extension."""
|
||||
event_with_unknown_ext = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.zzunknown999",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(TypeError, match="filetype cannot be None"):
|
||||
s3_parser.parse(event_with_unknown_ext)
|
||||
|
||||
|
||||
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
|
||||
"""Test S3 parser keeps already encoded object keys compatible."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"recordings%2F{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_parser_accepts_unencoded_filepath(settings):
|
||||
"""Test S3 parser accepts raw object keys with slash separators."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"recordings/{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
|
||||
"""Test S3 parser preserves plus signs in already encoded object keys."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
||||
"""Test successful extraction of recording ID from S3 event."""
|
||||
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
# get_parser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_lru_cache():
|
||||
"""Fixture to clear the LRU cache between tests."""
|
||||
get_parser.cache_clear()
|
||||
yield
|
||||
get_parser.cache_clear()
|
||||
|
||||
|
||||
def test_returns_correct_instance(clear_lru_cache):
|
||||
"""Test if get_parser returns the correct parser instance."""
|
||||
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
||||
parser = get_parser()
|
||||
assert isinstance(parser, MinioParser)
|
||||
assert parser._bucket_name == "test-bucket"
|
||||
|
||||
|
||||
def test_caching_behavior(clear_lru_cache):
|
||||
"""Test if the function properly caches the parser instance."""
|
||||
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
||||
parser1 = get_parser()
|
||||
parser2 = get_parser()
|
||||
assert parser1 is parser2 # Check object identity
|
||||
|
||||
|
||||
def test_different_settings_new_instance():
|
||||
"""Test if changing settings creates a new instance."""
|
||||
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
|
||||
parser = get_parser()
|
||||
assert parser._bucket_name == "different-bucket"
|
||||
|
||||
|
||||
def test_import_error_handling(clear_lru_cache):
|
||||
"""Test handling of import errors for invalid parser class."""
|
||||
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
|
||||
with pytest.raises(ImportError):
|
||||
get_parser()
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.parsers.import_string")
|
||||
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
|
||||
"""Test that parser class is instantiated only once due to caching."""
|
||||
mock_parser_cls = type(
|
||||
"MockParser",
|
||||
(),
|
||||
{
|
||||
"__init__": lambda self, bucket_name: setattr(
|
||||
self, "_bucket_name", bucket_name
|
||||
)
|
||||
},
|
||||
)
|
||||
mock_import_string.return_value = mock_parser_cls
|
||||
|
||||
# First call
|
||||
parser1 = get_parser()
|
||||
# Second call
|
||||
parser2 = get_parser()
|
||||
|
||||
# Verify import_string was called only once
|
||||
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
|
||||
assert parser1 is parser2
|
||||
|
||||
|
||||
def test_cache_clear_behavior(clear_lru_cache, settings):
|
||||
"""Test that cache clearing creates new instance."""
|
||||
|
||||
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
|
||||
|
||||
parser1 = get_parser()
|
||||
get_parser.cache_clear()
|
||||
parser2 = get_parser()
|
||||
|
||||
assert parser1 is not parser2 # Should be different instances after cache clear
|
||||
@@ -12,6 +12,7 @@ from core.factories import RecordingFactory
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
@@ -70,3 +71,56 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["active", "stopped"])
|
||||
@pytest.mark.parametrize(
|
||||
("notify_return_value", "expected_status"),
|
||||
((True, "notification_succeeded"), (False, "saved")),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
expected_status,
|
||||
status,
|
||||
service,
|
||||
):
|
||||
"""Test handle_complete notifies external services and saves a savable recording."""
|
||||
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
service.handle_complete(recording)
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_complete_non_savable_recording(
|
||||
mock_notify_external_services, status, service
|
||||
):
|
||||
"""Test handle_complete refuses recordings that are already saved or in error."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
with pytest.raises(RecordingNotSavableError):
|
||||
service.handle_complete(recording)
|
||||
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: save recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recording_settings(settings):
|
||||
"""Configure recording-related and storage event Django settings."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_parser():
|
||||
"""Mock 'get_parser' factory function."""
|
||||
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
|
||||
yield mock_parser
|
||||
|
||||
|
||||
def test_save_recording_anonymous(settings, client):
|
||||
"""Anonymous users should not be allowed to save room recordings."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
|
||||
RecordingFactory(status="active")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_save_recording_wrong_bearer(settings, client):
|
||||
"""Requests with incorrect bearer token should be rejected when auth is required."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_save_recording_permission_needed(settings, client):
|
||||
"""Recordings should not be saved when feature is disabled."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of parsing errors in recording event data."""
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid request data."}
|
||||
|
||||
|
||||
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of invalid storage bucket errors in recording event data."""
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid bucket specified."}
|
||||
|
||||
|
||||
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||
"""Test handling of unsupported file types in recording event data."""
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
|
||||
"unsupported '.json'"
|
||||
)
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Notification ignored."}
|
||||
|
||||
|
||||
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
|
||||
"""Test handling of unsupported filepath in recording event data."""
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
|
||||
"Invalid filepath structure: parent/folder/recording.jpeg"
|
||||
)
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Notification ignored."}
|
||||
|
||||
|
||||
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of events for non-existent recordings."""
|
||||
|
||||
RecordingFactory(status="active")
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = uuid.uuid4()
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No recording found for this event."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
def test_save_recording_non_savable_recording(
|
||||
recording_settings, mock_get_parser, client, status
|
||||
):
|
||||
"""Test that recordings in non-savable states cannot be saved."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = recording.id
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
|
||||
" in an error state or has already been saved."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["active", "stopped"])
|
||||
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
|
||||
"""Test successful saving of recordings in valid states."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = recording.id
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@pytest.mark.parametrize("notification_succeeded", [True, False])
|
||||
def test_save_recording_notifies_external_services(
|
||||
mock_notify_external_services,
|
||||
recording_settings,
|
||||
mock_get_parser,
|
||||
client,
|
||||
notification_succeeded,
|
||||
):
|
||||
"""External services should be notified when a recording is saved."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = recording.id
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
mock_notify_external_services.return_value = notification_succeeded
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == (
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else RecordingStatusChoices.SAVED
|
||||
)
|
||||
@@ -389,7 +389,7 @@ def test_allow_participant_to_enter_anonymous():
|
||||
|
||||
def test_allow_participant_to_enter_non_owner():
|
||||
"""Non-privileged users should not be allowed to manage entry requests."""
|
||||
room = RoomFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -522,7 +522,7 @@ def test_list_waiting_participants_anonymous():
|
||||
|
||||
def test_list_waiting_participants_non_owner():
|
||||
"""Non-privileged users should not be allowed to list waiting participants."""
|
||||
room = RoomFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Trusted rooms: any authenticated participant present in the meeting can manage the lobby."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.presence import PresenceCache
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_trusted_room_present_user_can_list_waiting(mock_check):
|
||||
"""Authenticated + present in a trusted room -> 200, LiveKit asked once then cached."""
|
||||
mock_check.return_value = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
|
||||
assert client.get(url).status_code == 200
|
||||
assert client.get(url).status_code == 200
|
||||
assert mock_check.call_count == 1
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_trusted_room_absent_user_forbidden(mock_check):
|
||||
"""Authenticated but not connected to the meeting -> 403."""
|
||||
mock_check.return_value = False
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_restricted_room_present_user_forbidden(mock_check):
|
||||
"""Presence is not enough on a restricted room; LiveKit must not even be asked."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
assert response.status_code == 403
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_trusted_room_presence_cleared_after_leave(mock_check):
|
||||
"""Once the presence cache is cleared (participant_left), LiveKit is re-checked."""
|
||||
mock_check.return_value = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url = f"/api/v1.0/rooms/{room.id}/waiting-participants/"
|
||||
|
||||
assert client.get(url).status_code == 200
|
||||
PresenceCache().clear(room.id, str(user.sub))
|
||||
|
||||
mock_check.return_value = False
|
||||
assert client.get(url).status_code == 403
|
||||
assert mock_check.call_count == 2
|
||||
|
||||
|
||||
def test_trusted_room_anonymous_forbidden():
|
||||
"""Anonymous users never manage the lobby."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
response = APIClient().get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_trusted_room_present_user_can_accept_entry(mock_check):
|
||||
"""Authenticated + present in a trusted room can accept a waiting participant."""
|
||||
mock_check.return_value = True
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
# Permission passed; 404 because that participant isn't actually waiting.
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"message": "Participant not found."}
|
||||
@@ -75,12 +75,11 @@ def test_initialization(
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_success( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
@@ -159,12 +158,11 @@ def test_handle_egress_updated_non_handled(
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913, PLR0917 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||
def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop and save recording when metadata's update fails."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
@@ -358,12 +356,10 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
||||
recording_status,
|
||||
egress_status,
|
||||
service,
|
||||
settings,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should notify external services and save the recording on egress completion
|
||||
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED) when RECORDING_STORAGE_EVENT_ENABLE is False.
|
||||
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
|
||||
"""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
@@ -379,47 +375,6 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
||||
assert recording.status == recording_status
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status, expected_status",
|
||||
[
|
||||
(EgressStatus.EGRESS_COMPLETE, "active"),
|
||||
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
|
||||
],
|
||||
)
|
||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
expected_status,
|
||||
service,
|
||||
settings,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""When storage event webhooks are enabled, egress_ended must not finalize the
|
||||
recording: external services are never notified. EGRESS_LIMIT_REACHED still stops
|
||||
the recording, EGRESS_COMPLETE leaves it active.
|
||||
"""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[
|
||||
@@ -432,10 +387,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_metadata, egress_status, service, settings
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Shouldn't save on invalid status."""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -453,14 +407,13 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_metadata, status, service, settings
|
||||
mock_update_metadata, status, service
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
|
||||
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
||||
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
||||
"""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -854,3 +807,31 @@ def test_receive_ignores_connection_test_room(
|
||||
|
||||
mock_handle_room_started.assert_not_called()
|
||||
mock_handle_room_finished.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.presence.cache.delete")
|
||||
def test_participant_left_clearing_gated_by_setting(mock_delete, service, settings):
|
||||
"""PRESENCE_CLEAR_ON_PARTICIPANT_LEFT toggles eager presence invalidation."""
|
||||
data = mock.Mock()
|
||||
data.room.name = "room-name"
|
||||
data.participant.identity = "user-sub"
|
||||
|
||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = False
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.presence.cache.delete")
|
||||
def test_participant_left_without_identity_is_ignored(mock_delete, service, settings):
|
||||
"""No cache operation when the webhook carries no identity."""
|
||||
settings.PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = True
|
||||
data = mock.Mock()
|
||||
data.room.name = "room-name"
|
||||
data.participant.identity = ""
|
||||
|
||||
service._handle_participant_left(data) # pylint: disable=protected-access
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
@@ -24,6 +24,7 @@ from core.services.lobby import (
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -578,14 +579,14 @@ def test_get_participant_parsing_error(
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants when none exist."""
|
||||
mock_cache.keys.return_value = []
|
||||
mock_cache.iter_keys.return_value = []
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_not_called()
|
||||
|
||||
|
||||
@@ -594,7 +595,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.keys.return_value = [cache_key]
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
@@ -603,7 +604,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "test-username"
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||
|
||||
|
||||
@@ -628,7 +629,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
@@ -646,7 +647,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
assert all(p["status"] == "waiting" for p in result)
|
||||
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -655,7 +656,7 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.keys.return_value = [cache_key]
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
@@ -680,7 +681,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
|
||||
corrupted_participant = {"invalid": "data"}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: corrupted_participant,
|
||||
cache_key2: valid_participant,
|
||||
@@ -699,7 +700,7 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
|
||||
# Verify both cache keys were queried
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -723,7 +724,7 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests for the presence cache and the cached presence check."""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.presence import PresenceCache
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_presence_cache_hit_skips_livekit(mock_check):
|
||||
"""A cached positive answer must not call LiveKit."""
|
||||
room_id, identity = str(uuid4()), "user-sub"
|
||||
PresenceCache().mark_present(room_id, identity)
|
||||
|
||||
assert (
|
||||
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity) is True
|
||||
)
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_presence_cache_miss_calls_livekit_and_caches_positive(mock_check):
|
||||
"""On a miss, LiveKit is asked once and a positive answer is memoized."""
|
||||
mock_check.return_value = True
|
||||
room_id, identity = str(uuid4()), "user-sub"
|
||||
service = ParticipantsManagement()
|
||||
|
||||
assert service.check_if_in_meeting_cached(room_id, identity) is True
|
||||
assert service.check_if_in_meeting_cached(room_id, identity) is True
|
||||
assert mock_check.call_count == 1
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_presence_negative_not_cached(mock_check):
|
||||
"""Negative answers are never memoized."""
|
||||
mock_check.return_value = False
|
||||
room_id, identity = str(uuid4()), "user-sub"
|
||||
service = ParticipantsManagement()
|
||||
|
||||
assert service.check_if_in_meeting_cached(room_id, identity) is False
|
||||
assert service.check_if_in_meeting_cached(room_id, identity) is False
|
||||
assert mock_check.call_count == 2
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_presence_errors_propagate_and_cache_nothing(mock_check):
|
||||
"""LiveKit errors propagate to the caller (which fails closed); nothing cached."""
|
||||
room_id, identity = str(uuid4()), "user-sub"
|
||||
|
||||
for exc in (ParticipantNotFoundException(), ParticipantsManagementException()):
|
||||
mock_check.side_effect = exc
|
||||
with pytest.raises(type(exc)):
|
||||
ParticipantsManagement().check_if_in_meeting_cached(room_id, identity)
|
||||
assert cache.get(PresenceCache._get_cache_key(room_id, identity)) is None
|
||||
|
||||
|
||||
def test_presence_clear_and_clear_room():
|
||||
"""clear() removes one entry, clear_room() removes all entries of a room."""
|
||||
room_id, other_room = str(uuid4()), str(uuid4())
|
||||
presence = PresenceCache()
|
||||
presence.mark_present(room_id, "a")
|
||||
presence.mark_present(room_id, "b")
|
||||
presence.mark_present(other_room, "a")
|
||||
|
||||
presence.clear(room_id, "a")
|
||||
assert presence.is_marked_present(room_id, "a") is False
|
||||
assert presence.is_marked_present(room_id, "b") is True
|
||||
|
||||
presence.clear_room(room_id)
|
||||
assert presence.is_marked_present(room_id, "b") is False
|
||||
assert presence.is_marked_present(other_room, "a") is True
|
||||
|
||||
|
||||
def test_presence_clear_room_scans_in_pages():
|
||||
"""clear_room removes every match, even across several SCAN pages,
|
||||
and only within the room."""
|
||||
room_id, other_room = str(uuid4()), str(uuid4())
|
||||
presence = PresenceCache()
|
||||
for i in range(7):
|
||||
presence.mark_present(room_id, f"user-{i}")
|
||||
presence.mark_present(other_room, "user-0")
|
||||
|
||||
# An itersize smaller than the match count forces delete_pattern to
|
||||
# page through several SCAN cursors rather than finish in one pass.
|
||||
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
|
||||
presence.clear_room(room_id)
|
||||
|
||||
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
||||
assert presence.is_marked_present(other_room, "user-0") is True
|
||||
@@ -512,3 +512,6 @@ def build_telephony_config():
|
||||
"default_country": country,
|
||||
"international_phone_number": international,
|
||||
}
|
||||
|
||||
|
||||
CACHE_SCAN_ITERSIZE = 500
|
||||
|
||||
@@ -729,20 +729,6 @@ class Base(Configuration):
|
||||
environ_name="RECORDING_WORKER_CLASSES",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_EVENT_PARSER_CLASS = values.Value(
|
||||
"core.recording.event.parsers.MinioParser",
|
||||
environ_name="RECORDING_EVENT_PARSER_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_ENABLE_STORAGE_EVENT_AUTH = values.BooleanValue(
|
||||
True, environ_name="RECORDING_ENABLE_STORAGE_EVENT_AUTH", environ_prefix=None
|
||||
)
|
||||
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
|
||||
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
|
||||
)
|
||||
RECORDING_STORAGE_EVENT_TOKEN = SecretFileValue(
|
||||
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Number of days before recordings expire - must be synced with bucket lifecycle policy
|
||||
# Set to None for no expiration
|
||||
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
|
||||
@@ -856,6 +842,15 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
# Lobby configurations
|
||||
PRESENCE_KEY_PREFIX = values.Value(
|
||||
"room_presence", environ_name="PRESENCE_KEY_PREFIX", environ_prefix=None
|
||||
)
|
||||
PRESENCE_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
3600, environ_name="PRESENCE_CACHE_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
PRESENCE_CLEAR_ON_PARTICIPANT_LEFT = values.BooleanValue(
|
||||
True, environ_name="PRESENCE_CLEAR_ON_PARTICIPANT_LEFT", environ_prefix=None
|
||||
)
|
||||
LOBBY_KEY_PREFIX = values.Value(
|
||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.27.0"
|
||||
version = "1.29.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.27.0"
|
||||
version = "1.29.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Generated
+65
-48
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
@@ -16,10 +16,10 @@
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@pandacss/preset-panda": "1.12.0",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"hoofd": "1.7.3",
|
||||
@@ -27,9 +27,9 @@
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"i18next-resources-to-backend": "1.2.3",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.404.1",
|
||||
"posthog-js": "1.414.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
@@ -767,9 +767,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.2.6.tgz",
|
||||
"integrity": "sha512-D0Z8peFkRGYACvmWp8Sl8YPGYLBEJbzqapugtsMEYSUUtpQDiZVDHjk9s7bqB4hSCRYCrKuW6V1V+mnHybVnPw==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/atkinson-hyperlegible-next/-/atkinson-hyperlegible-next-5.3.0.tgz",
|
||||
"integrity": "sha512-UnLzNUy1RWoLDfydAatP4NaRm99X/WbQEph7q/PymJnCFIOOwKOx3pBmp5ys8trO0dM+CKGuJgeZP9fAR4mQpA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
@@ -1642,21 +1642,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/preset-panda": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.11.3.tgz",
|
||||
"integrity": "sha512-IHeRQyIYeGKpQl78A/oI9fGg8MHQiOX/HttH+iQNaRU/MbGN6JsPe/N0ekQC6QY8iiKtLhqR+SRc455SN6JVWw==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/preset-panda/-/preset-panda-1.12.0.tgz",
|
||||
"integrity": "sha512-jhzhAmtGY34b1FlMJGEAh/IkfU4WyTNT2mpJfcBUPBi1rZLHSTcs0aqedRmMdwM4tTT43XFOXMiazbztKME60A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pandacss/types": "1.11.3"
|
||||
"@pandacss/types": "1.12.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@pandacss/preset-panda/node_modules/@pandacss/types": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.11.3.tgz",
|
||||
"integrity": "sha512-tUbxa3WzoCuulP6kcMn7f0uNgLBvu81JAg4sfoUpz6PZE36VHA3k9n6ROEwziTbr7vqgeCj5DYQCUldrVNbEGQ==",
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@pandacss/types/-/types-1.12.0.tgz",
|
||||
"integrity": "sha512-PtlEIOBEV3RzWffeu1zH+/YFmP+fn24M63STGEbgTwjEBMhyo/FfT2sfZGB8oWAbWUBKBp69OMg25E+/rY3klQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -1719,19 +1719,29 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.48.3",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||
"node_modules/@posthog/browser-common": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
|
||||
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.405.0"
|
||||
"@posthog/core": "^1.46.8",
|
||||
"@posthog/types": "^1.402.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
|
||||
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.407.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.405.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||
"version": "1.407.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
|
||||
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-aria/overlays": {
|
||||
@@ -2414,9 +2424,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz",
|
||||
"integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2435,12 +2445,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.101.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||
"version": "5.101.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz",
|
||||
"integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.101.1"
|
||||
"@tanstack/query-core": "5.101.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -4784,13 +4794,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
|
||||
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
|
||||
"version": "3.4.14",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
|
||||
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -6975,9 +6982,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-resources-to-backend": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz",
|
||||
"integrity": "sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.3.tgz",
|
||||
"integrity": "sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
@@ -9123,19 +9131,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.404.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
|
||||
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
|
||||
"version": "1.414.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
|
||||
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.43.1",
|
||||
"@posthog/types": "^1.397.0",
|
||||
"@posthog/browser-common": "^0.4.0",
|
||||
"@posthog/core": "^1.46.9",
|
||||
"@posthog/types": "^1.402.2",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.3.2",
|
||||
"dompurify": "^3.4.12",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.29.3",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.3.0"
|
||||
"web-vitals": "^5.3.0",
|
||||
"web-vitals-soft-navs": "npm:web-vitals@6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
@@ -11647,6 +11657,13 @@
|
||||
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/web-vitals-soft-navs": {
|
||||
"name": "web-vitals",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
|
||||
"integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -15,7 +15,7 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
@@ -23,10 +23,10 @@
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@pandacss/preset-panda": "1.12.0",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@tanstack/react-query": "5.101.4",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"hoofd": "1.7.3",
|
||||
@@ -34,9 +34,9 @@
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"i18next-resources-to-backend": "1.2.3",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.404.1",
|
||||
"posthog-js": "1.414.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
|
||||
@@ -2,6 +2,12 @@ import type { CaptureResult } from 'posthog-js'
|
||||
|
||||
const IGNORED_EXCEPTION_PATTERNS = [
|
||||
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
||||
// livekit-client leaks the raw WebSocket error Event as an unhandled
|
||||
// rejection when the signal ws errors after connect (Firefox-heavy,
|
||||
// coincides with signal reconnects). Carries zero diagnostic content —
|
||||
// the close reason is already logged by the SDK.
|
||||
// See: https://github.com/livekit/client-sdk-js/issues/2062
|
||||
/^Event captured as exception with keys: isTrusted$/,
|
||||
]
|
||||
|
||||
const shouldIgnoreException = (value: unknown): boolean =>
|
||||
|
||||
@@ -137,6 +137,7 @@ export const captureMediaEvent = async (
|
||||
| 'media-device-topology'
|
||||
| 'media-device-success'
|
||||
| 'device-not-found'
|
||||
| 'device-in-use'
|
||||
| 'permissions-denied'
|
||||
| 'screen-share-permission-denied'
|
||||
| 'silent-mic-detected'
|
||||
|
||||
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
|
||||
mobile: {
|
||||
true: {
|
||||
justifyContent: 'center',
|
||||
width: '330px',
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
@@ -11,6 +11,7 @@ import { Div } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
import { setPinnedTrack } from '@/stores/layout'
|
||||
import { useParticipantTracks } from '@livekit/components-react'
|
||||
|
||||
const ClickableToast = styled(RACButton, {
|
||||
base: {
|
||||
@@ -30,13 +31,17 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
||||
)
|
||||
const participant = props.toast.content.participant
|
||||
|
||||
if (!participant) return
|
||||
const [cameraTrack] = useParticipantTracks(
|
||||
[Source.Camera],
|
||||
participant?.identity
|
||||
)
|
||||
|
||||
const trackReference = {
|
||||
participant,
|
||||
publication: participant.getTrackPublication(Source.Camera),
|
||||
source: Source.Camera,
|
||||
}
|
||||
const trackReference = useMemo(
|
||||
() => cameraTrack ?? { participant, source: Source.Camera },
|
||||
[cameraTrack, participant]
|
||||
)
|
||||
|
||||
if (!participant) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
|
||||
+11
-26
@@ -1,11 +1,9 @@
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { openMuteDialog } from '@/stores/muteDialog'
|
||||
|
||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
source: Track.Source.Microphone,
|
||||
})
|
||||
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => setIsAlertOpen(true)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => openMuteDialog(participant)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,13 +19,11 @@ import {
|
||||
import Source = Track.Source
|
||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { useState } from 'react'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { openMuteDialog } from '@/stores/muteDialog'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
|
||||
|
||||
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Source.Microphone,
|
||||
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
|
||||
const canMute = useCanMute(participant)
|
||||
const isSpeaking = useIsSpeaking(participant)
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
const label = isLocal(participant)
|
||||
@@ -52,46 +48,34 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={label}
|
||||
aria-label={label}
|
||||
isDisabled={isMuted || !canMute}
|
||||
onPress={async () =>
|
||||
!isMuted && isLocal(participant)
|
||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(
|
||||
false
|
||||
)
|
||||
: setIsAlertOpen(true)
|
||||
}
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
className={css({
|
||||
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||
animation: isSpeaking
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
<Button
|
||||
square
|
||||
variant="greyscale"
|
||||
size="sm"
|
||||
tooltip={label}
|
||||
aria-label={label}
|
||||
isDisabled={isMuted || !canMute}
|
||||
onPress={async () =>
|
||||
!isMuted && isLocal(participant)
|
||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
|
||||
: openMuteDialog(participant)
|
||||
}
|
||||
data-attr="participants-mute"
|
||||
>
|
||||
{isMuted ? (
|
||||
<RiMicOffFill color={'gray'} aria-hidden={true} />
|
||||
) : (
|
||||
<RiMicFill
|
||||
className={css({
|
||||
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
|
||||
animation: isSpeaking
|
||||
? 'pulse_background 800ms infinite'
|
||||
: undefined,
|
||||
})}
|
||||
aria-hidden={true}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
|
||||
import { useEnterRoom } from '../api/enterRoom'
|
||||
import {
|
||||
useListWaitingParticipants,
|
||||
type WaitingParticipant,
|
||||
} from '../../participants/api/listWaitingParticipants'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
|
||||
export const useWaitingParticipants = () => {
|
||||
const [listEnabled, setListEnabled] = useState(true)
|
||||
|
||||
const roomData = useRoomData()
|
||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||
|
||||
const room = useRoomContext()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const handleDataReceived = useCallback((payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
if (notification?.type === NotificationType.ParticipantWaiting) {
|
||||
setListEnabled(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdminOrOwner) {
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [isAdminOrOwner, room, handleDataReceived])
|
||||
const canManageLobby = useCanManageLobby()
|
||||
|
||||
const { data: waitingData, refetch: refetchWaiting } =
|
||||
useListWaitingParticipants(roomId, {
|
||||
retry: false,
|
||||
enabled: listEnabled && isAdminOrOwner,
|
||||
refetchInterval: POLL_INTERVAL_MS,
|
||||
refetchIntervalInBackground: true,
|
||||
enabled: false,
|
||||
})
|
||||
|
||||
const waitingParticipants = useMemo(
|
||||
() => waitingData?.participants || [],
|
||||
[waitingData]
|
||||
() => (canManageLobby ? waitingData?.participants || [] : []),
|
||||
[waitingData, canManageLobby]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingParticipants.length) setListEnabled(false)
|
||||
}, [waitingParticipants])
|
||||
|
||||
const { mutateAsync: enterRoom } = useEnterRoom()
|
||||
|
||||
const handleParticipantEntry = async (
|
||||
@@ -74,8 +44,6 @@ export const useWaitingParticipants = () => {
|
||||
allowEntry: boolean
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setListEnabled(false)
|
||||
|
||||
await Promise.all(
|
||||
waitingParticipants.map((participant) =>
|
||||
enterRoom({
|
||||
@@ -89,7 +57,6 @@ export const useWaitingParticipants = () => {
|
||||
await refetchWaiting()
|
||||
} catch (e) {
|
||||
reportError('generic_failure', e)
|
||||
setListEnabled(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
@@ -7,6 +6,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
|
||||
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
const focusReactionsToolbar = () => {
|
||||
document
|
||||
@@ -17,35 +18,44 @@ const focusReactionsToolbar = () => {
|
||||
|
||||
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
|
||||
|
||||
export const ReactionsToggle = () => {
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
export const reactionShortcutHandler = () => {
|
||||
if (layoutStore.showReactionsToolbar) {
|
||||
focusReactionsToolbar()
|
||||
} else {
|
||||
layoutStore.showReactionsToolbar = true
|
||||
}
|
||||
}
|
||||
|
||||
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
|
||||
|
||||
export const ReactionsToggle = ({
|
||||
variant = 'primaryDark',
|
||||
onPress,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
|
||||
const { isOpen, toggle } = useReactionsToolbar()
|
||||
|
||||
const handleShortcut = useCallback(() => {
|
||||
if (layoutStore.showReactionsToolbar) {
|
||||
focusReactionsToolbar()
|
||||
} else {
|
||||
layoutStore.showReactionsToolbar = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: handleShortcut,
|
||||
handler: reactionShortcutHandler,
|
||||
})
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
{...props}
|
||||
id={REACTIONS_TOGGLE_ID}
|
||||
data-attr="reactions-toggle"
|
||||
square
|
||||
variant="primaryDark"
|
||||
variant={variant}
|
||||
aria-label={t('button')}
|
||||
aria-expanded={isOpen}
|
||||
tooltip={t('button')}
|
||||
isSelected={isOpen}
|
||||
onChange={toggle}
|
||||
onPress={onPress}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { useStartRecording, useStopRecording } from '@/features/recording'
|
||||
import { recordingStore } from '@/stores/recording'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
|
||||
export const useMutateRecording = () => {
|
||||
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => {
|
||||
recordingStore.isErrorDialogOpen = 'start'
|
||||
captureEvent('error-starting-recording')
|
||||
},
|
||||
})
|
||||
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => {
|
||||
recordingStore.isErrorDialogOpen = 'stop'
|
||||
captureEvent('error-stopping-recording')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
usePersistentUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import {
|
||||
ConnectionError,
|
||||
ConnectionErrorReason,
|
||||
DisconnectReason,
|
||||
MediaDeviceFailure,
|
||||
Room,
|
||||
type RoomOptions,
|
||||
VideoPresets,
|
||||
} from 'livekit-client'
|
||||
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
@@ -25,7 +28,11 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||
import { LocalUserChoices } from '@/stores/userChoices'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import {
|
||||
captureEvent,
|
||||
captureMediaEvent,
|
||||
reportError,
|
||||
} from '@/features/analytics/telemetry'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
@@ -36,6 +43,7 @@ import { useSnapshot } from 'valtio'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { userStore } from '@/stores/user'
|
||||
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
|
||||
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -108,6 +116,7 @@ export const Conference = ({
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||
...VOICE_AUDIO_CONSTRAINTS,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||
@@ -225,8 +234,18 @@ export const Conference = ({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={(e) => {
|
||||
const failure = MediaDeviceFailure.getFailure(e)
|
||||
const failure = getMediaDeviceFailure(e)
|
||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||
|
||||
// connect() was aborted by a disconnect() before the join completed
|
||||
if (
|
||||
e instanceof ConnectionError &&
|
||||
e.reason === ConnectionErrorReason.Cancelled
|
||||
) {
|
||||
void captureEvent('connection-cancelled')
|
||||
return
|
||||
}
|
||||
|
||||
reportError('livekit_room_error', e, {
|
||||
path: 'connect_publish',
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
userChoicesStore,
|
||||
} from '@/stores/userChoices'
|
||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
|
||||
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
||||
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
||||
import { SilentMicDetector } from './SilentMicDetector'
|
||||
@@ -218,12 +219,14 @@ const switchTrackDevice =
|
||||
function getPreviewMessages({
|
||||
cameraFound,
|
||||
cameraDenied,
|
||||
cameraInUse,
|
||||
micDenied,
|
||||
videoEnabled,
|
||||
videoStarted,
|
||||
}: {
|
||||
cameraFound: boolean
|
||||
cameraDenied: boolean
|
||||
cameraInUse: boolean
|
||||
micDenied: boolean
|
||||
videoEnabled: boolean
|
||||
videoStarted: boolean
|
||||
@@ -235,6 +238,9 @@ function getPreviewMessages({
|
||||
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
||||
return { hint: key, permissionsButtonLabel: key }
|
||||
}
|
||||
if (cameraInUse) {
|
||||
return { hint: 'cameraInUse', permissionsButtonLabel: null }
|
||||
}
|
||||
if (!videoEnabled) {
|
||||
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
||||
}
|
||||
@@ -328,18 +334,20 @@ const VideoPreview = ({
|
||||
const cameraDenied = useCannotUseDevice('videoinput')
|
||||
const micDenied = useCannotUseDevice('audioinput')
|
||||
const cameraMissing = useDeviceMissing('videoinput')
|
||||
const cameraInUse = useDeviceInUse('videoinput')
|
||||
|
||||
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
||||
|
||||
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
||||
cameraFound: !cameraMissing,
|
||||
cameraDenied,
|
||||
cameraInUse,
|
||||
micDenied,
|
||||
videoEnabled,
|
||||
videoStarted,
|
||||
})
|
||||
|
||||
const isError = cameraMissing || cameraDenied
|
||||
const isError = cameraMissing || cameraDenied || cameraInUse
|
||||
|
||||
return (
|
||||
<div className={styles.previewFrame}>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useConnectionState, useRoomContext } from '@livekit/components-react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useCanManageLobby } from '@/features/rooms/livekit/hooks/useCanManageLobby'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useListWaitingParticipants } from '@/features/participants/api/listWaitingParticipants'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
import { usePrevious } from '@/hooks/usePrevious'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
export const LAZY_POLL_INTERVAL_MS = 10_000
|
||||
|
||||
export const LobbyProvider = () => {
|
||||
const room = useRoomContext()
|
||||
|
||||
const canManageLobby = useCanManageLobby()
|
||||
const roomData = useRoomData()
|
||||
const { isParticipantsOpen } = useSidePanel()
|
||||
const isConnected = useConnectionState(room) === ConnectionState.Connected
|
||||
|
||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||
|
||||
const { error: waitingError, refetch: refetchWaiting } =
|
||||
useListWaitingParticipants(roomId, {
|
||||
retry: false,
|
||||
enabled: canManageLobby && isConnected && !!roomId,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchInterval: (query) => {
|
||||
if (!query.state.data?.participants?.length) return false
|
||||
if (isParticipantsOpen) return POLL_INTERVAL_MS
|
||||
return LAZY_POLL_INTERVAL_MS
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
})
|
||||
|
||||
// Triggers: each one-shot, idempotent, deduped by React Query if
|
||||
// concurrent. The interval takes over whenever a fetch finds waiters.
|
||||
const fetchIfManager = useCallback(() => {
|
||||
if (canManageLobby) refetchWaiting()
|
||||
}, [canManageLobby, refetchWaiting])
|
||||
|
||||
// 1. Connection established (join or reconnect)
|
||||
useEffect(() => {
|
||||
room.on(RoomEvent.Connected, fetchIfManager)
|
||||
room.on(RoomEvent.Reconnected, fetchIfManager)
|
||||
return () => {
|
||||
room.off(RoomEvent.Connected, fetchIfManager)
|
||||
room.off(RoomEvent.Reconnected, fetchIfManager)
|
||||
}
|
||||
}, [room, fetchIfManager])
|
||||
|
||||
// 2. Someone started waiting (LiveKit broadcast).
|
||||
const handleDataReceived = useCallback(
|
||||
(payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
if (notification?.type === NotificationType.ParticipantWaiting) {
|
||||
fetchIfManager()
|
||||
}
|
||||
},
|
||||
[fetchIfManager]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (canManageLobby) {
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [canManageLobby, room, handleDataReceived])
|
||||
|
||||
// 3. Rights regained.
|
||||
const prevCanManageLobby = usePrevious(canManageLobby)
|
||||
useEffect(() => {
|
||||
if (!prevCanManageLobby && canManageLobby && isConnected) {
|
||||
fetchIfManager()
|
||||
}
|
||||
}, [
|
||||
prevCanManageLobby,
|
||||
canManageLobby,
|
||||
isParticipantsOpen,
|
||||
fetchIfManager,
|
||||
isConnected,
|
||||
])
|
||||
|
||||
const clearWaitingList = useCallback(() => {
|
||||
const queryKey = [keys.waitingParticipants, roomId]
|
||||
queryClient.cancelQueries({ queryKey })
|
||||
queryClient.setQueryData(queryKey, { participants: [] })
|
||||
}, [roomId])
|
||||
|
||||
// Rights lost mid-meeting (covers trusted -> restricted/public).
|
||||
useEffect(() => {
|
||||
if (prevCanManageLobby && !canManageLobby) clearWaitingList()
|
||||
}, [prevCanManageLobby, canManageLobby, clearWaitingList])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
waitingError instanceof ApiError &&
|
||||
[401, 403].includes(waitingError.statusCode)
|
||||
) {
|
||||
clearWaitingList()
|
||||
}
|
||||
}, [waitingError, clearWaitingList])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -15,7 +15,8 @@ const Card = styled('div', {
|
||||
marginTop: '1.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow: '',
|
||||
minWidth: '380px',
|
||||
width: '100%',
|
||||
maxWidth: '380px',
|
||||
minHeight: '196px',
|
||||
},
|
||||
})
|
||||
@@ -37,8 +38,11 @@ const ratingButtonRecipe = cva({
|
||||
color: 'initial',
|
||||
border: 'none',
|
||||
borderRadius: 0,
|
||||
padding: '0.5rem 0.85rem',
|
||||
padding: { base: '0.5rem 0.25rem', xsm: '0.5rem 0.85rem' },
|
||||
flexGrow: '1',
|
||||
flexBasis: 0,
|
||||
minWidth: 0,
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
variants: {
|
||||
@@ -99,7 +103,9 @@ const OpenFeedback = ({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<H lvl={3}>{t('question')}</H>
|
||||
<H lvl={3} centered>
|
||||
{t('question')}
|
||||
</H>
|
||||
<TextArea
|
||||
id="feedbackInput"
|
||||
name="feedback"
|
||||
@@ -168,7 +174,9 @@ const RateQuality = ({
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<H lvl={3}>{t('question')}</H>
|
||||
<H lvl={3} centered>
|
||||
{t('question')}
|
||||
</H>
|
||||
<Bar>
|
||||
{[...Array(maxRating)].map((_, index) => (
|
||||
<RACButton
|
||||
@@ -228,7 +236,9 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||
}}
|
||||
>
|
||||
<VStack gap={0}>
|
||||
<H lvl={3}>{t('heading')}</H>
|
||||
<H lvl={3} centered>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Text as="p" variant="paragraph" centered>
|
||||
{t('body')}
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
clearDeviceInUseForOtherDevice,
|
||||
deviceAvailabilityStore,
|
||||
} from '@/stores/deviceAvailability'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
|
||||
import type { PermissionKind } from '@/stores/permissions'
|
||||
|
||||
const RETRY_INTERVAL_MS = 30_000
|
||||
|
||||
/**
|
||||
* There is no browser event for "another app released the device", so
|
||||
* while a device is flagged in use, re-probe it periodically. The probe
|
||||
* targets the currently selected device (the flag is about what the app
|
||||
* would acquire, not about any device of the kind). Only clears the flag
|
||||
* (the toggle becomes usable again); it never re-enables the device on
|
||||
* the user's behalf.
|
||||
*/
|
||||
export function useWatchDeviceReleased() {
|
||||
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
||||
const { audioDeviceId, videoDeviceId } = useSnapshot(userChoicesStore)
|
||||
useWatchKind('camera', cameraInUse, videoDeviceId)
|
||||
useWatchKind('microphone', microphoneInUse, audioDeviceId)
|
||||
}
|
||||
|
||||
function useWatchKind(
|
||||
kind: PermissionKind,
|
||||
inUse: boolean,
|
||||
selectedDeviceId?: string
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (inUse) clearDeviceInUseForOtherDevice(kind, selectedDeviceId)
|
||||
}, [kind, inUse, selectedDeviceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!inUse) return
|
||||
let stopped = false
|
||||
let timer: number | undefined
|
||||
const tick = async () => {
|
||||
await probeDeviceReleased(kind, selectedDeviceId || undefined)
|
||||
if (!stopped) {
|
||||
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [kind, inUse, selectedDeviceId])
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { connectionObserverStore } from '@/stores/connectionObserver'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Stack } from '@/styled-system/jsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
@@ -134,7 +134,11 @@ export const IsIdleDisconnectModal = () => {
|
||||
</H>
|
||||
<Description />
|
||||
<Settings />
|
||||
<HStack marginTop="2rem">
|
||||
<Stack
|
||||
direction={{ base: 'column', xsm: 'row' }}
|
||||
align={{ base: 'stretch', xsm: 'center' }}
|
||||
marginTop="2rem"
|
||||
>
|
||||
<Button
|
||||
onPress={() => {
|
||||
connectionObserverStore.isIdleDisconnectModalOpen = false
|
||||
@@ -148,7 +152,7 @@ export const IsIdleDisconnectModal = () => {
|
||||
<Button onPress={close} size="sm" variant="primary">
|
||||
{t('stayButton')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useRef } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
|
||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
||||
|
||||
export const MuteAlertDialogProvider = () => {
|
||||
const { participant } = useSnapshot(muteDialogStore)
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
|
||||
const lastNameRef = useRef('')
|
||||
if (participant) {
|
||||
lastNameRef.current = participant.name || participant.identity
|
||||
}
|
||||
|
||||
return (
|
||||
<MuteAlertDialog
|
||||
isOpen={!!participant}
|
||||
name={lastNameRef.current}
|
||||
onClose={closeMuteDialog}
|
||||
onSubmit={() => {
|
||||
const target = muteDialogStore.participant
|
||||
if (!target) return
|
||||
muteParticipant(target).then(closeMuteDialog)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+1
-3
@@ -105,9 +105,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
|
||||
_initVirtualBackgroundImage() {
|
||||
if (this.options.type !== 'virtual') {
|
||||
throw new Error(
|
||||
'Virtual background is only supported for virtual background'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const needsUpdate =
|
||||
|
||||
+12
-6
@@ -5,16 +5,18 @@ import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type PermissionNeededButtonProps = {
|
||||
tooltip?: string
|
||||
onPress?: () => void
|
||||
tooltip?: string | null
|
||||
onPress?: (() => void) | null
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export const PermissionNeededButton = ({
|
||||
tooltip,
|
||||
onPress,
|
||||
ariaLabel,
|
||||
}: PermissionNeededButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
||||
const label = tooltip ?? t('tooltip')
|
||||
const label = tooltip === undefined ? t('tooltip') : tooltip
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -26,9 +28,13 @@ export const PermissionNeededButton = ({
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
aria-label={tooltip ? label : t('ariaLabel')}
|
||||
tooltip={label}
|
||||
onPress={onPress ?? (() => openPermissionsDialog())}
|
||||
aria-label={ariaLabel ?? label ?? t('ariaLabel')}
|
||||
tooltip={label ?? undefined}
|
||||
onPress={
|
||||
onPress === undefined
|
||||
? () => openPermissionsDialog()
|
||||
: (onPress ?? undefined)
|
||||
}
|
||||
variant="permission"
|
||||
>
|
||||
<div
|
||||
|
||||
+49
-19
@@ -20,8 +20,10 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
|
||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
||||
import { requestDevicePermission } from '../../../utils/mediaPermissions'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||
import type {
|
||||
@@ -97,38 +99,51 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
const deviceIcons = useDeviceIcons(kind)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
const deviceInUse = useDeviceInUse(kind)
|
||||
const explainDeviceInUse = deviceInUse && context === 'room'
|
||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||
const silentMicWarning =
|
||||
kind === 'audioinput' &&
|
||||
silentMicStatus === 'silent' &&
|
||||
!cannotUseDevice &&
|
||||
!deviceMissing
|
||||
!deviceMissing &&
|
||||
!deviceInUse
|
||||
const deviceShortcut = useDeviceShortcut(kind)
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const isRequestingPermission = useRef(false)
|
||||
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
|
||||
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
|
||||
|
||||
const mediaPath = context === 'join' ? 'join_preview' : 'room'
|
||||
|
||||
const onPress = async () => {
|
||||
if (!enabled && deviceMissing) {
|
||||
setShowDeviceNotFound(true)
|
||||
setAlertError(MediaDeviceFailure.NotFound)
|
||||
return
|
||||
}
|
||||
if (!cannotUseDevice) {
|
||||
if (!cannotUseDevice && !deviceInUse) {
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
if (isRequestingPermission.current) return
|
||||
isRequestingPermission.current = true
|
||||
try {
|
||||
const granted = await requestDevicePermission(
|
||||
const selectedDeviceId = cannotUseDevice
|
||||
? undefined
|
||||
: kind === 'videoinput'
|
||||
? userChoicesStore.videoDeviceId
|
||||
: userChoicesStore.audioDeviceId
|
||||
const acquired = await requestDevicePermission(
|
||||
kind,
|
||||
context === 'join' ? 'join_preview' : 'room'
|
||||
mediaPath,
|
||||
selectedDeviceId || undefined
|
||||
)
|
||||
if (granted) {
|
||||
if (acquired) {
|
||||
toggle()
|
||||
} else {
|
||||
} else if (cannotUseDevice) {
|
||||
openPermissionsDialog(kind)
|
||||
} else if (explainDeviceInUse) {
|
||||
setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||
}
|
||||
} finally {
|
||||
isRequestingPermission.current = false
|
||||
@@ -179,13 +194,34 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
return <ActiveSpeakerWrapper />
|
||||
}
|
||||
|
||||
const getToggleTooltip = () => {
|
||||
if (deviceMissing) return t(`deviceNotFound.${kind}`)
|
||||
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
|
||||
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
return toggleLabel
|
||||
}
|
||||
const toggleTooltip = getToggleTooltip()
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{(cannotUseDevice || deviceMissing) && (
|
||||
<PermissionNeededButton
|
||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||
onPress={
|
||||
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
||||
deviceMissing
|
||||
? () => setAlertError(MediaDeviceFailure.NotFound)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{deviceInUse && (
|
||||
<PermissionNeededButton
|
||||
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : null}
|
||||
ariaLabel={t(`deviceInUse.${kind}`)}
|
||||
onPress={
|
||||
explainDeviceInUse
|
||||
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -204,22 +240,16 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
shySelected
|
||||
onPress={onPress}
|
||||
aria-label={toggleLabel}
|
||||
tooltip={
|
||||
deviceMissing
|
||||
? t(`deviceNotFound.${kind}`)
|
||||
: cannotUseDevice
|
||||
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||
: toggleLabel
|
||||
}
|
||||
tooltip={toggleTooltip}
|
||||
{...computedToggleButtonProps}
|
||||
{...overrideToggleButtonProps}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
<MediaDeviceErrorAlert
|
||||
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
||||
error={alertError}
|
||||
kind={kind}
|
||||
onClose={() => setShowDeviceNotFound(false)}
|
||||
onClose={() => setAlertError(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,10 +10,18 @@ import {
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
const SPEAKING_DETECTION_DELAY = 3000
|
||||
|
||||
export const HandToggle = () => {
|
||||
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
|
||||
|
||||
export const HandToggle = ({
|
||||
variant = 'primaryDark',
|
||||
onPress,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||
|
||||
const room = useRoomContext()
|
||||
@@ -74,12 +82,16 @@ export const HandToggle = () => {
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
{...props}
|
||||
square
|
||||
variant="primaryDark"
|
||||
variant={variant}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={handleToggle}
|
||||
onPress={(e) => {
|
||||
handleToggle()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
|
||||
@@ -37,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
LIGHT = 5,
|
||||
NORMAL = 10,
|
||||
LIGHT = 10,
|
||||
NORMAL = 20,
|
||||
}
|
||||
|
||||
const isSupported = BackgroundProcessorFactory.isSupported()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
import { useRoomData } from './useRoomData'
|
||||
|
||||
export const useCanManageLobby = () => {
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const { isLoggedIn } = useUser()
|
||||
const roomData = useRoomData()
|
||||
|
||||
return (
|
||||
(isAdminOrOwner ||
|
||||
(isLoggedIn === true &&
|
||||
roomData?.access_level === ApiAccessLevel.TRUSTED)) &&
|
||||
roomData?.access_level !== ApiAccessLevel.PUBLIC
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
||||
import { useCannotUseDevice } from './useCannotUseDevice'
|
||||
import { useDeviceMissing } from './useDeviceMissing'
|
||||
|
||||
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
|
||||
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
||||
const cannotUseDevice = useCannotUseDevice(kind)
|
||||
const deviceMissing = useDeviceMissing(kind)
|
||||
|
||||
if (cannotUseDevice || deviceMissing) return false
|
||||
|
||||
switch (kind) {
|
||||
case 'videoinput':
|
||||
return cameraInUse
|
||||
case 'audioinput':
|
||||
return microphoneInUse
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -10,16 +10,15 @@ import {
|
||||
} from 'livekit-client'
|
||||
import { BackgroundProcessorFactory } from '../components/blur'
|
||||
import {
|
||||
classifyPermissionError,
|
||||
isLikelySystemNotFound,
|
||||
isSystemPermissionError,
|
||||
noteGumSuccess,
|
||||
notePermissionDeniedFromGum,
|
||||
noteSystemPermissionDenied,
|
||||
type PermissionKind,
|
||||
} from '@/stores/permissions'
|
||||
import { getOS } from '@/utils/os'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import {
|
||||
getMediaDeviceFailure,
|
||||
noteDeviceReady,
|
||||
onMediaPermissionError,
|
||||
} from '../utils/mediaPermissions'
|
||||
import { VOICE_AUDIO_CONSTRAINTS } from '../utils/constants'
|
||||
import {
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
@@ -29,74 +28,6 @@ import {
|
||||
} from '@/stores/userChoices'
|
||||
import { useSyncTrackDeviceId } from './useSyncTrackDeviceId'
|
||||
|
||||
const VOICE_AUDIO_CONSTRAINTS = {
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
voiceIsolation: false,
|
||||
sampleRate: 48000,
|
||||
channelCount: 1,
|
||||
sampleSize: 16,
|
||||
} as const
|
||||
|
||||
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
|
||||
audioinput: 'microphone',
|
||||
videoinput: 'camera',
|
||||
}
|
||||
|
||||
type MediaPath = 'join_preview' | 'room'
|
||||
|
||||
const onMediaPermissionError = (
|
||||
e: Error,
|
||||
kind?: PermissionKind,
|
||||
path: MediaPath = 'join_preview'
|
||||
) => {
|
||||
if (
|
||||
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
|
||||
) {
|
||||
void classifyPermissionError(e, kind).then((scope) => {
|
||||
if (scope === 'system') {
|
||||
noteSystemPermissionDenied(kind)
|
||||
} else {
|
||||
notePermissionDeniedFromGum(kind)
|
||||
}
|
||||
captureMediaEvent('permissions-denied', {
|
||||
path,
|
||||
kind,
|
||||
denied_scope: scope,
|
||||
os: getOS(),
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
|
||||
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
||||
// settings, missing Android app permissions).
|
||||
void isLikelySystemNotFound(e, kind).then((system) => {
|
||||
if (system) {
|
||||
noteSystemPermissionDenied(kind)
|
||||
captureMediaEvent('permissions-denied', {
|
||||
path,
|
||||
kind,
|
||||
denied_scope: 'system',
|
||||
os: getOS(),
|
||||
})
|
||||
return
|
||||
}
|
||||
captureMediaEvent('device-not-found', { path, kind })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
|
||||
reportError(
|
||||
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
||||
e,
|
||||
{ path, kind }
|
||||
)
|
||||
}
|
||||
|
||||
// Module-level: effect dependencies, must be referentially stable.
|
||||
const disableAudio = () => saveAudioInputEnabled(false)
|
||||
const disableVideo = () => saveVideoInputEnabled(false)
|
||||
@@ -104,24 +35,6 @@ const disableVideo = () => saveVideoInputEnabled(false)
|
||||
const stopAll = (stream: MediaStream) =>
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
|
||||
export const requestDevicePermission = async (
|
||||
kind: 'audioinput' | 'videoinput',
|
||||
path: MediaPath = 'join_preview'
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const track =
|
||||
kind === 'audioinput'
|
||||
? await createLocalAudioTrack()
|
||||
: await createLocalVideoTrack()
|
||||
track.stop()
|
||||
noteGumSuccess(PERMISSION_KIND[kind])
|
||||
return true
|
||||
} catch (error) {
|
||||
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type WarmupState = {
|
||||
audioReady: boolean
|
||||
videoReady: boolean
|
||||
@@ -158,11 +71,11 @@ function useWarmupPermissions(): WarmupState {
|
||||
video: true,
|
||||
})
|
||||
)
|
||||
noteGumSuccess()
|
||||
noteDeviceReady()
|
||||
bothReady()
|
||||
} catch (error) {
|
||||
if (
|
||||
MediaDeviceFailure.getFailure(error as Error) ===
|
||||
getMediaDeviceFailure(error as Error) ===
|
||||
MediaDeviceFailure.PermissionDenied &&
|
||||
!isSystemPermissionError(error)
|
||||
) {
|
||||
@@ -180,7 +93,7 @@ function useWarmupPermissions(): WarmupState {
|
||||
.getUserMedia({ audio: true })
|
||||
.then((stream) => {
|
||||
stopAll(stream)
|
||||
noteGumSuccess('microphone')
|
||||
noteDeviceReady('microphone')
|
||||
})
|
||||
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||
.finally(() =>
|
||||
@@ -190,7 +103,7 @@ function useWarmupPermissions(): WarmupState {
|
||||
.getUserMedia({ video: true })
|
||||
.then((stream) => {
|
||||
stopAll(stream)
|
||||
noteGumSuccess('camera')
|
||||
noteDeviceReady('camera')
|
||||
})
|
||||
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
||||
.finally(() =>
|
||||
@@ -209,12 +122,14 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
||||
enabled,
|
||||
create,
|
||||
permissionKind,
|
||||
deviceId,
|
||||
onFailure,
|
||||
}: {
|
||||
ready: boolean
|
||||
enabled: boolean
|
||||
create: () => Promise<T>
|
||||
permissionKind: PermissionKind
|
||||
deviceId?: string
|
||||
onFailure: () => void
|
||||
}): T | null {
|
||||
const [track, setTrack] = useState<T | null>(null)
|
||||
@@ -227,21 +142,27 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
||||
let cancelled = false
|
||||
create()
|
||||
.then((newTrack) => {
|
||||
noteGumSuccess(permissionKind)
|
||||
if (cancelled) {
|
||||
newTrack.stop()
|
||||
return
|
||||
}
|
||||
noteDeviceReady(permissionKind)
|
||||
setTrack(newTrack)
|
||||
})
|
||||
.catch((error) => {
|
||||
onMediaPermissionError(error as Error, permissionKind)
|
||||
if (cancelled) return
|
||||
onMediaPermissionError(
|
||||
error as Error,
|
||||
permissionKind,
|
||||
'join_preview',
|
||||
deviceId
|
||||
)
|
||||
onFailure()
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [ready, enabled, track, create, permissionKind, onFailure])
|
||||
}, [ready, enabled, track, create, permissionKind, deviceId, onFailure])
|
||||
|
||||
// Release on toggle-off so the LED turns off.
|
||||
useEffect(() => {
|
||||
@@ -315,6 +236,7 @@ export function useJoinTracks(): {
|
||||
enabled: audioEnabled,
|
||||
create: createAudio,
|
||||
permissionKind: 'microphone',
|
||||
deviceId: audioDeviceId,
|
||||
onFailure: disableAudio,
|
||||
})
|
||||
|
||||
@@ -323,6 +245,7 @@ export function useJoinTracks(): {
|
||||
enabled: videoEnabled,
|
||||
create: createVideo,
|
||||
permissionKind: 'camera',
|
||||
deviceId: videoDeviceId,
|
||||
onFailure: disableVideo,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
MediaDeviceFailure,
|
||||
RoomEvent,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import {
|
||||
PERMISSION_BY_DEVICE_KIND,
|
||||
type PermissionDeniedScope,
|
||||
@@ -10,8 +15,13 @@ import {
|
||||
notePermissionDeniedFromGum,
|
||||
noteSystemPermissionDenied,
|
||||
} from '@/stores/permissions'
|
||||
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
||||
import {
|
||||
clearDeviceInUse,
|
||||
noteDeviceInUse,
|
||||
syncDeviceAvailability,
|
||||
} from '@/stores/deviceAvailability'
|
||||
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
||||
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
|
||||
import { getOS } from '@/utils/os'
|
||||
|
||||
type MediaDeviceAlert = {
|
||||
@@ -21,6 +31,11 @@ type MediaDeviceAlert = {
|
||||
|
||||
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
|
||||
|
||||
const PERMISSION_BY_SOURCE: Partial<Record<Track.Source, PermissionKind>> = {
|
||||
[Track.Source.Camera]: 'camera',
|
||||
[Track.Source.Microphone]: 'microphone',
|
||||
}
|
||||
|
||||
const capturePermissionsDenied = (
|
||||
scope: PermissionDeniedScope,
|
||||
kind?: PermissionKind
|
||||
@@ -49,7 +64,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
||||
|
||||
useEffect(() => {
|
||||
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
||||
const failure = MediaDeviceFailure.getFailure(error)
|
||||
const failure = getMediaDeviceFailure(error)
|
||||
if (!failure) return
|
||||
if (failure != MediaDeviceFailure.Other) {
|
||||
void captureMediaEvent('media-device-error', {
|
||||
@@ -63,6 +78,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
||||
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||
switch (failure) {
|
||||
case MediaDeviceFailure.DeviceInUse:
|
||||
if (permissionKind) noteDeviceInUse(permissionKind)
|
||||
setAlert({ error: failure, kind })
|
||||
break
|
||||
case MediaDeviceFailure.NotFound:
|
||||
@@ -92,9 +108,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
||||
break
|
||||
}
|
||||
}
|
||||
const onTrackPublished = (publication: LocalTrackPublication) => {
|
||||
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
|
||||
if (permissionKind) clearDeviceInUse(permissionKind)
|
||||
}
|
||||
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
||||
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
|
||||
return () => {
|
||||
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
||||
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
|
||||
clearDeviceInUse()
|
||||
}
|
||||
}, [room])
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { RefObject, useMemo, useState } from 'react'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
@@ -21,7 +21,14 @@ export function DesktopControlBar({
|
||||
onDeviceError,
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [controlBarElement, setControlBarElement] =
|
||||
useState<HTMLDivElement | null>(null)
|
||||
|
||||
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
|
||||
() => ({ current: controlBarElement }),
|
||||
[controlBarElement]
|
||||
)
|
||||
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||
|
||||
@@ -45,7 +52,7 @@ export function DesktopControlBar({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
ref={setControlBarElement}
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ControlBarAuxProps } from './ControlBar'
|
||||
import React from 'react'
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { Track } from 'livekit-client'
|
||||
@@ -26,7 +26,26 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||
import {
|
||||
ReactionsToggle,
|
||||
reactionShortcutHandler,
|
||||
} from '@/features/reactions/components/ReactionsToggle'
|
||||
import { useSize } from '../../hooks/useResizeObserver'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
|
||||
// Hand collapses first, then reactions; hidden toggles move into the menu.
|
||||
const COLLAPSIBLE_COUNT = 2
|
||||
|
||||
// Layout-neutral measuring wrapper: inherits the region's gap and refuses to
|
||||
// flex-shrink so measured widths are natural content widths.
|
||||
const measuredRow = css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'inherit',
|
||||
flexShrink: 0,
|
||||
})
|
||||
|
||||
export function MobileControlBar({
|
||||
onDeviceError,
|
||||
@@ -36,50 +55,107 @@ export function MobileControlBar({
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const { toggleEffects } = useSidePanel()
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { width } = useSize(containerRef)
|
||||
const barRef = useRef<HTMLDivElement>(null)
|
||||
const { width: barWidth } = useSize(barRef)
|
||||
const collapsibleRef = useRef<HTMLDivElement>(null)
|
||||
const { width: collapsibleWidth } = useSize(collapsibleRef)
|
||||
|
||||
const [hiddenCount, setHiddenCount] = useState(0)
|
||||
const calibration = useRef<{ essential: number; slot: number }>()
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (hiddenCount === 0 && collapsibleWidth > 0 && barRef.current) {
|
||||
const gap = parseFloat(getComputedStyle(barRef.current).columnGap) || 0
|
||||
calibration.current = {
|
||||
essential: barWidth - collapsibleWidth - gap,
|
||||
slot: (collapsibleWidth + gap) / COLLAPSIBLE_COUNT,
|
||||
}
|
||||
}
|
||||
if (!calibration.current || width <= 0) return
|
||||
const { essential, slot } = calibration.current
|
||||
const fits = Math.floor((width - essential) / slot)
|
||||
const next = Math.min(
|
||||
COLLAPSIBLE_COUNT,
|
||||
Math.max(0, COLLAPSIBLE_COUNT - fits)
|
||||
)
|
||||
if (next !== hiddenCount) setHiddenCount(next)
|
||||
}, [barWidth, collapsibleWidth, hiddenCount, width, setHiddenCount])
|
||||
|
||||
const hideHand = hiddenCount >= 1
|
||||
const hideReactions = hiddenCount >= 2
|
||||
|
||||
const room = useRoomContext()
|
||||
const { toggleRaisedHand } = useRaisedHand({
|
||||
participant: room.localParticipant,
|
||||
})
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'raise-hand',
|
||||
handler: toggleRaisedHand,
|
||||
})
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: reactionShortcutHandler,
|
||||
})
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
const closeMenu = () => setIsMenuOpened(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
padding: '1.125rem',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<ControlBarRegion mobile>
|
||||
<LeaveButton />
|
||||
<AudioDevicesControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<VideoDeviceControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<ReactionsToggle />
|
||||
<HandToggle />
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
onPress={() => setIsMenuOpened(true)}
|
||||
>
|
||||
<RiMore2Line />
|
||||
</Button>
|
||||
</ControlBarRegion>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<ControlBarRegion mobile>
|
||||
<div ref={barRef} className={measuredRow}>
|
||||
<LeaveButton />
|
||||
<AudioDevicesControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<VideoDeviceControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
{/* Unmounted when empty so it doesn't leave a stray gap. */}
|
||||
{!hideReactions && (
|
||||
<div ref={collapsibleRef} className={measuredRow}>
|
||||
<ReactionsToggle />
|
||||
{!hideHand && <HandToggle />}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
onPress={() => setIsMenuOpened(true)}
|
||||
>
|
||||
<RiMore2Line />
|
||||
</Button>
|
||||
</div>
|
||||
</ControlBarRegion>
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveMenu
|
||||
isOpened={isMenuOpened}
|
||||
onClosed={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<ResponsiveMenu isOpened={isMenuOpened} onClosed={closeMenu}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -98,6 +174,20 @@ export function MobileControlBar({
|
||||
},
|
||||
})}
|
||||
>
|
||||
{hideReactions && (
|
||||
<ReactionsToggle
|
||||
variant="primaryTextDark"
|
||||
description={true}
|
||||
onPress={closeMenu}
|
||||
/>
|
||||
)}
|
||||
{hideHand && (
|
||||
<HandToggle
|
||||
variant="primaryTextDark"
|
||||
description={true}
|
||||
onPress={closeMenu}
|
||||
/>
|
||||
)}
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
onDeviceError={(error) =>
|
||||
@@ -105,25 +195,16 @@ export function MobileControlBar({
|
||||
}
|
||||
variant="primaryTextDark"
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
onPress={closeMenu}
|
||||
/>
|
||||
)}
|
||||
<ChatToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<ParticipantsToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<ToolsToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<ChatToggle description={true} onPress={closeMenu} />
|
||||
<ParticipantsToggle description={true} onPress={closeMenu} />
|
||||
<ToolsToggle description={true} onPress={closeMenu} />
|
||||
<Button
|
||||
onPress={() => {
|
||||
toggleEffects()
|
||||
setIsMenuOpened(false)
|
||||
closeMenu()
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
aria-label={t('options.items.effects')}
|
||||
@@ -140,7 +221,7 @@ export function MobileControlBar({
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
onPress={closeMenu}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
@@ -148,7 +229,7 @@ export function MobileControlBar({
|
||||
<Button
|
||||
onPress={() => {
|
||||
openSettingsDialog()
|
||||
setIsMenuOpened(false)
|
||||
closeMenu()
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
aria-label={t('options.items.settings')}
|
||||
@@ -157,7 +238,7 @@ export function MobileControlBar({
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
</Button>
|
||||
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
|
||||
<CameraSwitchButton onPress={closeMenu} />
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveMenu>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isWeb } from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { MediaDeviceFailure, Track } from 'livekit-client'
|
||||
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
ConnectionStateToast,
|
||||
@@ -19,6 +20,7 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||
@@ -29,6 +31,7 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
|
||||
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
||||
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
||||
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
||||
import { LobbyProvider } from '@/features/rooms/components/LobbyProvider'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -99,6 +102,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (getMediaDeviceFailure(error) !== MediaDeviceFailure.Other) {
|
||||
return
|
||||
}
|
||||
|
||||
reportError('device_switch_failure', error, {
|
||||
at: 'ControlBar.onDeviceError',
|
||||
source,
|
||||
@@ -113,6 +121,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<RoomSilentMicDetector />
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<LobbyProvider />
|
||||
<VideoResolutionSubscription />
|
||||
<div
|
||||
className="lk-video-conference"
|
||||
@@ -144,6 +153,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<ConnectionStateToast />
|
||||
<RecordingProvider />
|
||||
<SettingsDialogProvider />
|
||||
<MuteAlertDialogProvider />
|
||||
<ReactionPortals />
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const VOICE_AUDIO_CONSTRAINTS = {
|
||||
noiseSuppression: true,
|
||||
echoCancellation: true,
|
||||
autoGainControl: true,
|
||||
voiceIsolation: false,
|
||||
sampleRate: 48000,
|
||||
channelCount: 1,
|
||||
sampleSize: 16,
|
||||
} as const
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
createLocalAudioTrack,
|
||||
createLocalVideoTrack,
|
||||
MediaDeviceFailure,
|
||||
} from 'livekit-client'
|
||||
import {
|
||||
classifyPermissionError,
|
||||
isLikelySystemNotFound,
|
||||
noteGumSuccess,
|
||||
notePermissionDeniedFromGum,
|
||||
noteSystemPermissionDenied,
|
||||
type PermissionKind,
|
||||
} from '@/stores/permissions'
|
||||
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import { getOS } from '@/utils/os'
|
||||
|
||||
/**
|
||||
* Shared handling of getUserMedia() outcomes, used by both:
|
||||
* - the join preview (`useJoinTracks`: warmup + local track acquisition)
|
||||
* - the in-room device toggle (`ToggleDevice`, when permission is missing)
|
||||
*/
|
||||
export type MediaPath = 'join_preview' | 'room'
|
||||
|
||||
const DEVICE_START_FAILURE =
|
||||
/^(Starting (video|audio)input failed|Timeout starting (video|audio) source)/i
|
||||
|
||||
/**
|
||||
* LiveKit only maps NotReadableError/TrackStartError to DeviceInUse.
|
||||
* Firefox reports a device held by another app as
|
||||
* `AbortError: Starting videoinput failed` (or audioinput), which LiveKit
|
||||
* classifies as Other. Normalise it here; every classification in the app
|
||||
* should go through this instead of MediaDeviceFailure.getFailure.
|
||||
*/
|
||||
export const getMediaDeviceFailure = (
|
||||
error: Error
|
||||
): MediaDeviceFailure | undefined => {
|
||||
const failure = MediaDeviceFailure.getFailure(error)
|
||||
if (
|
||||
failure === MediaDeviceFailure.Other &&
|
||||
error.name === 'AbortError' &&
|
||||
DEVICE_START_FAILURE.test(error.message)
|
||||
) {
|
||||
return MediaDeviceFailure.DeviceInUse
|
||||
}
|
||||
return failure
|
||||
}
|
||||
|
||||
export const PERMISSION_KIND: Record<
|
||||
'audioinput' | 'videoinput',
|
||||
PermissionKind
|
||||
> = {
|
||||
audioinput: 'microphone',
|
||||
videoinput: 'camera',
|
||||
}
|
||||
|
||||
export const noteDeviceReady = (kind?: PermissionKind) => {
|
||||
noteGumSuccess(kind)
|
||||
clearDeviceInUse(kind)
|
||||
}
|
||||
|
||||
export const onMediaPermissionError = (
|
||||
e: Error,
|
||||
kind?: PermissionKind,
|
||||
path: MediaPath = 'join_preview',
|
||||
deviceId?: string
|
||||
) => {
|
||||
const failure = getMediaDeviceFailure(e)
|
||||
|
||||
if (failure === MediaDeviceFailure.PermissionDenied) {
|
||||
void classifyPermissionError(e, kind).then((scope) => {
|
||||
if (scope === 'system') {
|
||||
noteSystemPermissionDenied(kind)
|
||||
} else {
|
||||
notePermissionDeniedFromGum(kind)
|
||||
}
|
||||
captureMediaEvent('permissions-denied', {
|
||||
path,
|
||||
kind,
|
||||
denied_scope: scope,
|
||||
os: getOS(),
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (failure === MediaDeviceFailure.NotFound) {
|
||||
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
||||
// settings, missing Android app permissions).
|
||||
void isLikelySystemNotFound(e, kind).then((system) => {
|
||||
if (system) {
|
||||
noteSystemPermissionDenied(kind)
|
||||
captureMediaEvent('permissions-denied', {
|
||||
path,
|
||||
kind,
|
||||
denied_scope: 'system',
|
||||
os: getOS(),
|
||||
})
|
||||
return
|
||||
}
|
||||
captureMediaEvent('device-not-found', { path, kind })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (failure === MediaDeviceFailure.DeviceInUse) {
|
||||
noteDeviceInUse(kind, deviceId)
|
||||
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
|
||||
return
|
||||
}
|
||||
|
||||
// "Other" is still reported as an error.
|
||||
reportError(
|
||||
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
||||
e,
|
||||
{ path, kind }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Silent availability check for a device that was reported "in use":
|
||||
* acquires and releases it without any error reporting, so it can be
|
||||
* polled. Clears the in-use flag on success.
|
||||
*/
|
||||
export const probeDeviceReleased = async (
|
||||
kind: PermissionKind,
|
||||
deviceId?: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const constraint = deviceId ? { deviceId: { exact: deviceId } } : true
|
||||
const stream = await navigator.mediaDevices.getUserMedia(
|
||||
kind === 'camera' ? { video: constraint } : { audio: constraint }
|
||||
)
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
noteDeviceReady(kind)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers the browser permission prompt for one device kind by acquiring
|
||||
* and immediately releasing a track. Resolves to whether access was granted.
|
||||
*/
|
||||
export const requestDevicePermission = async (
|
||||
kind: 'audioinput' | 'videoinput',
|
||||
path: MediaPath = 'join_preview',
|
||||
deviceId?: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const track =
|
||||
kind === 'audioinput'
|
||||
? await createLocalAudioTrack({ deviceId })
|
||||
: await createLocalVideoTrack({ deviceId })
|
||||
track.stop()
|
||||
noteDeviceReady(PERMISSION_KIND[kind])
|
||||
return true
|
||||
} catch (error) {
|
||||
onMediaPermissionError(
|
||||
error as Error,
|
||||
PERMISSION_KIND[kind],
|
||||
path,
|
||||
deviceId
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { Center, HStack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { Center, Stack, styled, VStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Rating } from '@/features/rooms/components/Rating.tsx'
|
||||
import { useLocation } from 'wouter'
|
||||
import { useMemo } from 'react'
|
||||
@@ -14,14 +15,18 @@ const Heading = styled('h1', {
|
||||
fontStyle: 'normal',
|
||||
fontStretch: 'normal',
|
||||
fontOpticalSizing: 'auto',
|
||||
fontSize: '2.3rem',
|
||||
lineHeight: '2.5rem',
|
||||
fontSize: { base: '1.75rem', xsm: '2.3rem' },
|
||||
lineHeight: { base: '2.125rem', xsm: '2.5rem' },
|
||||
letterSpacing: '0',
|
||||
paddingBottom: '2rem',
|
||||
paddingBottom: { base: '1.5rem', xsm: '2rem' },
|
||||
textAlign: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const buttonClass = css({
|
||||
width: { base: '100%', xsm: 'auto' },
|
||||
})
|
||||
|
||||
enum DisconnectReasonKey {
|
||||
DuplicateIdentity = 'duplicateIdentity',
|
||||
ParticipantRemoved = 'participantRemoved',
|
||||
@@ -54,19 +59,31 @@ const FeedbackRoute = () => {
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<Center>
|
||||
<VStack>
|
||||
<Center width="100%">
|
||||
<VStack width="100%" paddingX="1rem">
|
||||
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
||||
<HStack>
|
||||
<Stack
|
||||
direction={{ base: 'column', xsm: 'row' }}
|
||||
width={{ base: '100%', xsm: 'auto' }}
|
||||
maxWidth="380px"
|
||||
>
|
||||
{showBackButton && (
|
||||
<Button variant="secondary" onPress={() => window.history.back()}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className={buttonClass}
|
||||
onPress={() => window.history.back()}
|
||||
>
|
||||
{t('feedback.back')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onPress={() => setLocation('/')}>
|
||||
<Button
|
||||
variant="primary"
|
||||
className={buttonClass}
|
||||
onPress={() => setLocation('/')}
|
||||
>
|
||||
{t('feedback.home')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
<Rating metadata={metadata} />
|
||||
</VStack>
|
||||
</Center>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
import { LogLevel, setLogLevel } from 'livekit-client'
|
||||
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
||||
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
|
||||
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
||||
|
||||
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||
@@ -49,6 +50,7 @@ const Room = () => {
|
||||
|
||||
useKeyboardShortcuts()
|
||||
useWatchDeviceAvailability()
|
||||
useWatchDeviceReleased()
|
||||
|
||||
const clearRouterState = () => {
|
||||
if (window?.history?.state) {
|
||||
|
||||
@@ -75,15 +75,15 @@ const useTranscriptionState = () => {
|
||||
const segment = segments[0]
|
||||
|
||||
setTranscriptionSegments((prevSegments) => {
|
||||
const existingSegmentIds = new Set(prevSegments.map((s) => s.id))
|
||||
if (existingSegmentIds.has(segment.id)) return prevSegments
|
||||
return [
|
||||
...prevSegments,
|
||||
{
|
||||
participant: participant,
|
||||
...segment,
|
||||
},
|
||||
]
|
||||
const existingIndex = prevSegments.findIndex(
|
||||
(s: TranscriptionSegmentWithParticipant) => s.id === segment.id
|
||||
)
|
||||
if (existingIndex === -1) {
|
||||
return [...prevSegments, { participant, ...segment }]
|
||||
}
|
||||
const next = prevSegments.slice()
|
||||
next[existingIndex] = { ...next[existingIndex], ...segment }
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
||||
},
|
||||
"deviceInUse": {
|
||||
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
||||
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Audioeinstellungen",
|
||||
"video": "Videoeinstellungen"
|
||||
@@ -67,6 +71,7 @@
|
||||
},
|
||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
||||
"cameraStarting": "Kamera wird gestartet…",
|
||||
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
||||
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"videoinput": "No camera detected. Check that it is properly wired.",
|
||||
"audioinput": "No microphone detected. Check that it is properly wired."
|
||||
},
|
||||
"deviceInUse": {
|
||||
"videoinput": "Camera unavailable: it is probably in use by another application or browser tab.",
|
||||
"audioinput": "Microphone unavailable: it is probably in use by another application or browser tab."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Audio settings",
|
||||
"video": "Video settings"
|
||||
@@ -67,6 +71,7 @@
|
||||
},
|
||||
"cameraDisabled": "Camera is disabled.",
|
||||
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
||||
"cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.",
|
||||
"cameraStarting": "Camera is starting…",
|
||||
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
||||
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
||||
},
|
||||
"deviceInUse": {
|
||||
"videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.",
|
||||
"audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Paramètres audio",
|
||||
"video": "Paramètres video"
|
||||
@@ -67,6 +71,7 @@
|
||||
},
|
||||
"cameraDisabled": "La caméra est désactivée.",
|
||||
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||
"cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.",
|
||||
"cameraStarting": "La caméra va démarrer…",
|
||||
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
|
||||
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
||||
},
|
||||
"deviceInUse": {
|
||||
"videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
||||
"audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad."
|
||||
},
|
||||
"settings": {
|
||||
"audio": "Audio-instellingen",
|
||||
"video": "Video-instellingen"
|
||||
@@ -67,6 +71,7 @@
|
||||
},
|
||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||
"cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
||||
"cameraStarting": "Camera wordt ingeschakeld…",
|
||||
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
||||
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
||||
|
||||
@@ -1,14 +1,68 @@
|
||||
import { proxy } from 'valtio'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import type { PermissionKind } from './permissions'
|
||||
|
||||
// Device presence (not permission): enumerateDevices() exposes kinds
|
||||
// before any grant. Optimistic defaults until the first sync.
|
||||
export const deviceAvailabilityStore = proxy({
|
||||
// Device availability (not permission):
|
||||
// - presence: enumerateDevices() exposes kinds before any grant.
|
||||
// Optimistic defaults until the first sync.
|
||||
// - in use: the device exists and is allowed, but getUserMedia() failed
|
||||
// because another application or tab is holding it. The id of the
|
||||
// affected device is kept (when known) so that a selection change can
|
||||
// invalidate a flag that no longer concerns the selected device.
|
||||
export const deviceAvailabilityStore = proxy<{
|
||||
hasCamera: boolean
|
||||
hasMicrophone: boolean
|
||||
cameraInUse: boolean
|
||||
microphoneInUse: boolean
|
||||
cameraInUseDeviceId?: string
|
||||
microphoneInUseDeviceId?: string
|
||||
synced: boolean
|
||||
}>({
|
||||
hasCamera: true,
|
||||
hasMicrophone: true,
|
||||
cameraInUse: false,
|
||||
microphoneInUse: false,
|
||||
synced: false,
|
||||
})
|
||||
|
||||
const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
|
||||
camera: 'cameraInUse',
|
||||
microphone: 'microphoneInUse',
|
||||
}
|
||||
|
||||
const IN_USE_DEVICE_KEY: Record<
|
||||
PermissionKind,
|
||||
'cameraInUseDeviceId' | 'microphoneInUseDeviceId'
|
||||
> = {
|
||||
camera: 'cameraInUseDeviceId',
|
||||
microphone: 'microphoneInUseDeviceId',
|
||||
}
|
||||
|
||||
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
|
||||
|
||||
export const noteDeviceInUse = (kind?: PermissionKind, deviceId?: string) => {
|
||||
for (const k of kind ? [kind] : ALL_KINDS) {
|
||||
deviceAvailabilityStore[IN_USE_KEY[k]] = true
|
||||
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = deviceId
|
||||
}
|
||||
}
|
||||
|
||||
export const clearDeviceInUse = (kind?: PermissionKind) => {
|
||||
for (const k of kind ? [kind] : ALL_KINDS) {
|
||||
deviceAvailabilityStore[IN_USE_KEY[k]] = false
|
||||
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export const clearDeviceInUseForOtherDevice = (
|
||||
kind: PermissionKind,
|
||||
selectedDeviceId?: string
|
||||
) => {
|
||||
const affected = deviceAvailabilityStore[IN_USE_DEVICE_KEY[kind]]
|
||||
if (!affected || !selectedDeviceId || affected === selectedDeviceId) return
|
||||
clearDeviceInUse(kind)
|
||||
}
|
||||
|
||||
export const syncDeviceAvailability = async (): Promise<void> => {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { proxy, ref } from 'valtio'
|
||||
import type { Participant } from 'livekit-client'
|
||||
|
||||
type State = {
|
||||
participant: Participant | null
|
||||
}
|
||||
|
||||
export const muteDialogStore = proxy<State>({
|
||||
participant: null,
|
||||
})
|
||||
|
||||
export const openMuteDialog = (participant: Participant) => {
|
||||
muteDialogStore.participant = ref(participant)
|
||||
}
|
||||
|
||||
export const closeMuteDialog = () => {
|
||||
muteDialogStore.participant = null
|
||||
}
|
||||
@@ -5,16 +5,26 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import svgr from 'vite-plugin-svgr'
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
|
||||
const mediapipeVersion: string = JSON.parse(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'./node_modules/@mediapipe/tasks-vision/package.json',
|
||||
import.meta.url
|
||||
),
|
||||
'utf-8'
|
||||
)
|
||||
const readPackageJson = (path: string) =>
|
||||
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf-8'))
|
||||
|
||||
const mediapipeVersion: string = readPackageJson(
|
||||
'./node_modules/@mediapipe/tasks-vision/package.json'
|
||||
).version
|
||||
|
||||
const livekitMediapipeVersion: string = readPackageJson(
|
||||
'./node_modules/@livekit/track-processors/package.json'
|
||||
).dependencies['@mediapipe/tasks-vision']
|
||||
|
||||
if (mediapipeVersion !== livekitMediapipeVersion) {
|
||||
throw new Error(
|
||||
`@mediapipe/tasks-vision@${mediapipeVersion} is installed, but ` +
|
||||
`@livekit/track-processors declares "${livekitMediapipeVersion}". ` +
|
||||
`The two must stay in sync: pin "@mediapipe/tasks-vision" to ` +
|
||||
`"${livekitMediapipeVersion}" in package.json.`
|
||||
)
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd())
|
||||
|
||||
@@ -186,8 +186,6 @@ backend:
|
||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||
# Recording & Transcription
|
||||
RECORDING_ENABLE: True
|
||||
RECORDING_STORAGE_EVENT_ENABLE: True
|
||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
|
||||
SUMMARY_SERVICE_API_TOKEN: password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
|
||||
|
||||
@@ -113,26 +113,3 @@ spec:
|
||||
exit 0
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: minio-webhook
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mc
|
||||
image: minio/mc
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint="https://meet.127.0.0.1.nip.io/api/v1.0/recordings/storage-hook/" auth_token="Bearer password" && \
|
||||
/usr/bin/mc admin service restart meet --wait --json && \
|
||||
sleep 15 && \
|
||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" && \
|
||||
exit 0
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
|
||||
Generated
+20
-10
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
"mjml": "5.4.0"
|
||||
}
|
||||
},
|
||||
@@ -52,14 +52,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@html-to/text-cli": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz",
|
||||
"integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==",
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz",
|
||||
"integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||
"aspargvs": "~0.7.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"deepmerge-ts": "^8.0.1",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"selderee": "~0.12.0"
|
||||
},
|
||||
@@ -656,9 +656,19 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
|
||||
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "ko-fi",
|
||||
"url": "https://ko-fi.com/rebeccastevens"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
"mjml": "5.4.0"
|
||||
},
|
||||
"private": true,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.27.0",
|
||||
"version": "1.29.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -22,13 +22,6 @@ Configure your env values in `env.d/summary` to properly set up WhisperX and the
|
||||
make run
|
||||
```
|
||||
|
||||
When the stack is up, configure the MinIO webhook
|
||||
*(TODO: add this step to `make bootstrap`)*
|
||||
|
||||
```sh
|
||||
make minio-webhook-setup
|
||||
```
|
||||
|
||||
If you want to develop on the Celery workers with hot reloading, run:
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.27.0"
|
||||
version = "1.29.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Analytics classes."""
|
||||
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
from collections import Counter
|
||||
from functools import lru_cache
|
||||
from functools import cached_property, lru_cache
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import redis
|
||||
@@ -33,6 +34,10 @@ class Analytics:
|
||||
logger.info("Initialize analytics client")
|
||||
self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host)
|
||||
|
||||
@cached_property
|
||||
def _hostname(self):
|
||||
return socket.gethostname()
|
||||
|
||||
@property
|
||||
def is_disabled(self):
|
||||
"""Check if analytics client is disabled or not configured."""
|
||||
@@ -43,6 +48,8 @@ class Analytics:
|
||||
if self.is_disabled:
|
||||
return
|
||||
|
||||
properties = {**(properties or {}), "_hostname": self._hostname}
|
||||
|
||||
try:
|
||||
self._client.capture(
|
||||
event_name, distinct_id=distinct_id, properties=properties
|
||||
|
||||
@@ -83,6 +83,7 @@ class Settings(BaseSettings):
|
||||
aws_s3_access_key_id: str
|
||||
aws_s3_secret_access_key: SecretStr
|
||||
aws_s3_secure_access: bool = True
|
||||
aws_s3_region_name: str | None = None
|
||||
aws_transcript_path: str = "transcripts"
|
||||
aws_summary_path: str = "summaries"
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ class FileService:
|
||||
access_key=settings.aws_s3_access_key_id,
|
||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
secure=settings.aws_s3_secure_access,
|
||||
region=settings.aws_s3_region_name,
|
||||
)
|
||||
|
||||
self._bucket_name = settings.aws_storage_bucket_name
|
||||
|
||||
Reference in New Issue
Block a user